#1 Extend now actually extends. useSasquatchPro collected receipts and took the max of (receipt.created_at + tierDays), so buying again before expiry under-delivered. It now stacks every valid proof with renewal semantics (expiry = max(expiry, proofTime) + days), client and server alike. #2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32 kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely dependent on the Lightning provider publishing zap receipts. New owner-only GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook reads via querySkateContent. Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and the Netlify function accept the new payload and a bare receipt (old APKs). Proxy was deployed first and verified healthy before this commit.
1941 lines
No EOL
113 KiB
Markdown
1941 lines
No EOL
113 KiB
Markdown
# Project Overview
|
||
|
||
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
|
||
|
||
## Technology Stack
|
||
|
||
- **React 18.x**: Stable version of React with hooks, concurrent rendering, and improved performance
|
||
- **TailwindCSS 3.x**: Utility-first CSS framework for styling
|
||
- **Vite**: Fast build tool and development server
|
||
- **shadcn/ui**: Unstyled, accessible UI components built with Radix UI and Tailwind
|
||
- **Nostrify**: Nostr protocol framework for Deno and web
|
||
- **React Router**: `react-router-dom` with **HashRouter** (routes live in the URL hash, e.g. `/#/settings`) and ScrollToTop functionality
|
||
- **TanStack Query**: For data fetching, caching, and state management
|
||
- **TypeScript**: For type-safe JavaScript development
|
||
|
||
## 🧠 This file is the ONLY persistent memory — keep it in sync
|
||
|
||
`AGENTS.md` is the single thing carried across sessions; a model starts every
|
||
session with **no other memory of this project**. So:
|
||
|
||
- **Write important things down here in the same session you learn them.** If it
|
||
isn't in this file, the next agent doesn't know it and will re-derive (or get)
|
||
it wrong.
|
||
- **Record the non-obvious**: exact commands + flags, file paths, gotchas,
|
||
environment quirks, verified facts, and *why* a decision was made. Skip what
|
||
anyone can read from the code.
|
||
- **Fix stale entries immediately.** A wrong note here makes every future session
|
||
go off the rails — check claims against the repo before trusting them.
|
||
- **Keep it tight and factual**: commands/paths over prose, no essays.
|
||
- When you change something this file describes (scripts, kinds, deploy, hooks),
|
||
update that section as part of the same change. Date non-trivial claims:
|
||
`(verified 2026-09-26)`.
|
||
|
||
### Project map (what exists right now)
|
||
- **Router**: `react-router-dom` **HashRouter** — real routes live in the URL
|
||
hash (`/#/settings`, `/#/marketplace`). A bare `/settings` renders the
|
||
homepage (this silently broke the boot smoke test once). `src/AppRouter.tsx`.
|
||
- **Custom protocol**: `NIP.md` is the source of truth for every custom/adopted
|
||
kind; the constants live in `src/lib/types.ts`. Highlights:
|
||
- `30818` skate spot · `30017` shop · `31923`/`31925` events + RSVP · `30823` tours
|
||
- `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades
|
||
- `30403` marketplace offer (+ seller response) — see `NIP.md`
|
||
- `31871` trade attestation (sale/purchase) · `31872` trade confirmation request
|
||
· `31873` trusted-trader recommendation
|
||
→ `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`;
|
||
reputation UI in `TradeReputationCard` / `TradeRecommendationsCard`
|
||
- `35000–35014` role profiles (skater/filmer/coach/…/event-organizer/barber) · `35006` crews · `30003` spot collections
|
||
- **Lab offer flows** (`/become/*`): the 7 craft roles share
|
||
`CraftProfileForm`; coach/filmer/editor/photographer/builder are bespoke but
|
||
follow the same conventions. Forms **prefill from the existing profile** to
|
||
edit, and **preserve the `g` geohash + paid `featured` tag on edit**. Bare
|
||
domains (`example.com`) are coerced to `https://` by **`normalizeUrlInput()`**
|
||
(`src/lib/sanitizeUrl.ts`) before storage, so links aren't silently hidden by
|
||
`sanitizeUrl()` at render (the extra-field `url` type used to be dropped
|
||
outright). `MIN_TITLE_LEN` is **1**: a profile title falls back to the
|
||
display name, and a 1-char name must not fail validation and vanish.
|
||
Contact fields are validated on submit: a non-empty email must satisfy
|
||
`isValidEmail()`, and website/portfolio must resolve through
|
||
`sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and
|
||
blocks publish (`social` is exempt — it may be a bare `@handle`).
|
||
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
|
||
image input — **drag & drop + multi-select**, emits `File[]` to its caller
|
||
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
||
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
|
||
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
|
||
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||
— one implementation). Buyer/seller attestations build mutual, WoT-weighted
|
||
reputation. **Anti-collusion (2026-09-27):** two accounts can fake mutual
|
||
confirmations, so reputation leads with **`verifiedCounterparties`** (distinct
|
||
people mutually confirmed with) instead of the raw `verified` count, weights
|
||
by WoT with a **per-partner cap of 3**, and shows a warning when several
|
||
confirmations all come from one account (`TradeReputationCard`). `NIP.md` →
|
||
"Trust model, sybil/collusion limits" documents that attestations are a
|
||
signal, **not proof of payment** — never present them as trustless. Confirming
|
||
a trade also records an optional **1–10 deck `rating`** (see `rating` tag).
|
||
**No payments are implemented** — "Buy" is intentionally absent;
|
||
buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades")
|
||
manages incoming requests, pending confirmations, and the user's trade records,
|
||
and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute
|
||
or mistake. Structured **offers** (kind `30403`, `useMarketplaceOffers` + the
|
||
`ListingOffersPanel`/`OfferDialog`) let a buyer propose an amount and the seller
|
||
accept/decline.
|
||
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||
`cashu_escrow` pubkey in kind 0. UI wired: `useCashuEscrow`,
|
||
`EscrowKeyCard` (Wallet), `EscrowRequestDialog` (listing card), `EscrowPanel`
|
||
+ redeem (DM request banner), `Refund escrow` (listing card). **Native-only and
|
||
UNVERIFIED on a device** — verify with the on-device checklist in `ESCROW.md`
|
||
(tiny amounts) before trusting it with real money.
|
||
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
||
web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`).
|
||
- **Relays**: NIP-65 read/write (kind 10002), NIP-17 **DM inbox relays**
|
||
(kind 10050; set in the Relays UI, fetched on login by `NostrSync`, delivered
|
||
to on send and read from in `useDirectMessages`), plus two **app-local**
|
||
categories — `localRelays` (never published; added to reads+writes) and
|
||
`blockedRelays` (excluded everywhere). `resolveRelays()` in `src/lib/relays.ts`
|
||
applies them; `NostrProvider` uses it for its req/event routers.
|
||
Deliberately **not** shipped: KeyPackage relays (`10051`, no MLS consumer yet),
|
||
"Trusted" (no distinct behaviour — would be empty UI), and Amethyst's proxy/
|
||
broadcast/private-home (we don't have that relay-discovery model) — don't add
|
||
UI for features we don't implement.
|
||
- All relay consumers now go through **`useRelays()`** (read/write — blocklist
|
||
applied, local included): listing reports, seller info, skate relays, zaps,
|
||
Sasquatch purchase, collections, crew form, and DMs. AppProvider's
|
||
empty-relays defaults check is the only intentional direct reader.
|
||
- DM **inbox** relays (kind 10050) are also **block-filtered** now (reads and
|
||
delivery), so the blocklist applies to DMs too.
|
||
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
|
||
- ⚠️ **Zod strips undeclared keys — it cost us the DM relay list (fixed
|
||
2026-09-27).** `AppProvider` validates the persisted config with
|
||
`AppConfigSchema.partial().parse(...)`, and `RelayMetadataSchema` only declared
|
||
`relays` + `updatedAt`. Zod's default behaviour is to **drop unknown keys**, so
|
||
`dmRelays`, `localRelays` and `blockedRelays` were **wiped on every load**.
|
||
The DM inbox relays (kind 10050) are usually NOT in a user's NIP-65 list, so
|
||
with `dmRelays` gone the app read/wrote DMs only over NIP-65 relays — where
|
||
the other side isn't listening — and inbox messages appeared to never arrive.
|
||
**Rule: any new `RelayMetadata`/`AppConfig` field MUST be added to the Zod
|
||
schema** or it silently won't persist. Verified end-to-end: before the fix the
|
||
gift wrap reached only write relays (recipient found 0); after, it lands on
|
||
the configured inbox relays (recipient and self-fetch both find it).
|
||
- ⚠️ **DM history read from the wrong relays — "No messages yet" with a full
|
||
inbox (fixed 2026-09-28).** All four DM read hooks built their relay list
|
||
from `config.relayMetadata.dmRelays` (login-time sync only). If that was
|
||
stale/empty, only the NIP-65 read relays were queried — and gift wraps
|
||
almost never live there (real case: 94 addressed wraps on the user's inbox
|
||
relay `nostr.mom`, **0–1** on their NIP-65 relays). Fix: **`useResolvedDmRelays()`**
|
||
in `useDirectMessages.ts` discovers the user's own **kind-10050** inbox relays
|
||
at query time (per-relay, 6s each, over the read relays + `INBOX_DISCOVERY_RELAYS`)
|
||
and unions them in — so a stale config can never silently hide history. Used
|
||
by `useConversation`/`useConversations`/`useGroupConversations`/`useGroupConversation`.
|
||
**`useConversations` now returns `ConversationsResult` (`{ conversations,
|
||
undecryptable, wraps }`)** — consumers are `DirectMessagesPage` and
|
||
`ContactsPage` — and the DM page shows "N encrypted messages couldn't be
|
||
opened" so a decrypt failure is no longer indistinguishable from an empty
|
||
inbox. Diagnostic recipe: count `{#p:[pubkey], kinds:[1059]}` per relay
|
||
(per-relay, not one pool query) before assuming a code bug.
|
||
- **DM send/read robustness (same date):** gift-wrap publishing and gift-wrap
|
||
reading were both **sequential with one shared timeout**, so a single dead
|
||
relay (e.g. `wotr.relatr.xyz`) or an auth-required one (`auth.nostr1.com`)
|
||
stalled the send or aborted the remaining reads — hiding every message. Both
|
||
are now **parallel with per-relay timeouts**; the send **resolves on the first
|
||
relay that accepts** and continues the rest in the background, and a recipient
|
||
with no discoverable 10050 falls back to their **NIP-65 write relays**
|
||
(previously the NIP-65 fallback was effectively dead code because our own DM
|
||
relays are always non-empty). `NRelay1.event()` DOES throw on `OK:false`, so
|
||
per-relay failures are real; the composer only clears on success.
|
||
- **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat
|
||
queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and
|
||
does **not** use the ContextVM server. AI goes through our own **server-side
|
||
proxy** — the Netlify Function `netlify/functions/ai.mjs` at `/api/ai` — which
|
||
runs against **Netlify's AI Gateway**. No key is managed or shipped:
|
||
Netlify injects `OPENROUTER_API_KEY` + `OPENROUTER_BASE_URL` into Functions,
|
||
and the function reads the base URL (so it also works if you later set your
|
||
own key). Model choice is a curated allowlist (the gateway enforces Zero Data
|
||
Retention, so gemini/anthropic/gpt-4o-mini are **not** routable) — keep
|
||
`ALLOWED_MODELS` in the function in sync with `PROXY_MODELS` in
|
||
`sasquatchBrain.ts`. Keyless **Pollinations** is the fallback.
|
||
The proxy has an **env-gated Pro paywall** and it is **ENABLED in production**
|
||
(`SASQUATCH_REQUIRE_PRO=1`): `/api/ai` requires `x-sasquatch-auth` (a NIP-98
|
||
kind-27235 event) plus `x-sasquatch-pro` (the Pro zap receipt, verified
|
||
offline incl. paid-amount→tier); platform owners bypass. Disable with
|
||
`netlify env:unset SASQUATCH_REQUIRE_PRO`. The client (`src/lib/proAuth.ts`)
|
||
signs NIP-98 once per session (no Amber pop per message) and attaches the
|
||
receipt — used by BOTH `SasquatchGuide` and the crew agent
|
||
(`useCrewAgentChat`). Callers without credentials 401 and fall back to
|
||
keyless **Pollinations**. The pay-to address is single-sourced in
|
||
`src/lib/platform.ts`.
|
||
The upstream is **provider-agnostic**: `SASQUATCH_AI_BASE` + `SASQUATCH_AI_KEY`
|
||
override the Netlify gateway (any OpenAI-compatible endpoint). For a
|
||
**sats-only** provider use Routstr (`SASQUATCH_AI_BASE=https://api.routstr.com/v1`,
|
||
key = a cashu…/sk- session token; `SASQUATCH_MODELS` must match `PROXY_MODELS`).
|
||
⚠️ The **aggregator `api.routstr.com` is DOWN** (404) — use a **node**.
|
||
Nodes announce on Nostr as **kind 38421** with the endpoint in a `u` tag.
|
||
**Live + working (2026-09-27): `https://routstr.githappens.space`** (accepts
|
||
the minibits mint; `gemini-3-6-flash` answers). Others were up too but
|
||
`ai.orangesync.tech` issued an `sk-` key that its own inference endpoint
|
||
rejected ("User not found") — always test inference before committing funds.
|
||
Funding: `POST <node>/lightning/invoice {amount_sats,model}` → pay bolt11 →
|
||
`GET <node>/lightning/invoice/<id>/status` → `api_key` (`sk-…`). Refund:
|
||
`POST <node>/v1/balance/refund` → a Cashu token (recoverable/portable).
|
||
Then set `SASQUATCH_AI_BASE=<node>/v1`, `SASQUATCH_AI_KEY=sk-…`,
|
||
`SASQUATCH_MODELS=<slugs>` — **Netlify snapshots env at deploy, so redeploy
|
||
after changing it**. The proxy substitutes its first allowed model when the
|
||
client's slug isn't allowed, so the client list need not match.
|
||
Owner Settings shows the upstream balance (`SasquatchAiStatusCard`) via the
|
||
owner-only `POST /api/ai/status` (NIP-98 + owner pubkey → `handleStatus`), with
|
||
a "low balance" warning below 500 sats — the top-up nudge (no webhook needed).
|
||
The same balance shows as a chip in the Sasquatch panel header (owner-only,
|
||
fetched only while the panel is open so it never prompts at startup).
|
||
**AI answers are Pro-only**: free users get the static FAQ/glossary + local
|
||
relay lookups only (`if (isPro)` gates the AI block in `SasquatchGuide`).
|
||
Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt,
|
||
and its **expiry is derived from the paid amount → tier**, never the
|
||
user-signed `pro-expiry` tag.
|
||
- **Pro proofs STACK (fixed 2026-09-28).** `useSasquatchPro` now collects ALL
|
||
valid proofs and accumulates `expiry = max(expiry, proofTime) + tierDays`
|
||
(renewal semantics) — before, it took the **max** of
|
||
`receipt.created_at + days`, so buying again before expiry silently
|
||
under-delivered ("Extend" did not extend). The proof sent to the proxy is
|
||
now `{ receipts, grants }` (base64) instead of one receipt; **both servers
|
||
(`ai-proxy/server.ts` and `netlify/functions/ai.mjs`) accept the new payload
|
||
AND a bare receipt**, so installed APKs keep working. ⚠️ Deploy the **proxy
|
||
before the web client** (old proxy + new payload = 402).
|
||
- **Owner-grant fallback (NIP-32, same date):** `org.skatespots.pro` kind-1985
|
||
labels authored by an owner pubkey, tagged `p` = user + `expiry`, are a
|
||
trusted Pro source (`useSasquatchPro` + both servers honour them) — the
|
||
safety net if the LN provider ever stops emitting zap receipts. Published by
|
||
the owner-only **`GrantProCard`** (Settings → Advanced), written to
|
||
`getWriteRelays()` and read via `querySkateContent` (community relay + user
|
||
relays); the self-signed `32671` remains advisory-only.
|
||
`sasquatchBrain.ts` caps each request (15 s) and the whole attempt
|
||
(35 s); the system prompt is a real `system` message; relay results are
|
||
signature-verified (`verifyEvent`) before use. The first pass **streams**
|
||
(`streamSasquatchAI`, OpenAI-style SSE) into a live bubble — raw JSON tool
|
||
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
|
||
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
|
||
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
|
||
- **ContextVM (CVM) server** (`cvm-server/`, Bun): standalone MCP-over-Nostr
|
||
endpoint for *external* agents. Runs on this machine as a **system** service
|
||
(`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart
|
||
skatespots-cvm`) + a daily-restart timer. Re-publishes its discovery
|
||
announcements on a **heartbeat** (`CVM_HEARTBEAT_MS`, default 15 min) so the
|
||
owner Settings health card (stale after 45 min) stays green; `LOG_LEVEL` is
|
||
now wired to the transport. Relays come from `NOSTR_RELAYS` in the gitignored
|
||
`cvm-server/.env` — policy is **single community relay**
|
||
(`wss://goskatespots.nostr1.com`). The tracked manifest is `package.cvm.json`. Tools:
|
||
`find_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) /
|
||
`find_gear` / `find_crews` / `find_shops` / `find_tours` +
|
||
`get_community_stats` — kept at parity with the app's `useCVMClient`. Has a
|
||
per-pubkey sliding-window + in-flight throttle, a global upstream semaphore
|
||
and a short-TTL query cache (`CVM_RATE_*`, `CVM_MAX_*`, `CVM_QUERY_CACHE_MS`).
|
||
Type-checked by `cvm-server/tsconfig.json` via the `typecheck:cvm` script
|
||
(wired into `npm test`); `check:cvm` runs a Bun transpile.
|
||
**The server runs on one external host (not this box)** — deploy with
|
||
`npm run deploy:cvm`; the host/SSH values come from the never-committed
|
||
`~/.config/skatespots/cvm.env` (never put them in a tracked file — this repo
|
||
is public). `scripts/deploy-cvm.sh --rollback` restores the newest backup.
|
||
The discovery relay (`relay.contextvm.org`) is the real single point of
|
||
failure, not the server.
|
||
- **Routing / directions**: `RouteDialog` is the single in-app router, opened
|
||
from the map spot sheet, the food cards, and the spot / shop / event detail
|
||
pages (MapPage's old external "Open with" Google/OSM/Waze menu was removed).
|
||
Road modes (skate-walk / bike / moto / car) come from keyless **OSRM**
|
||
(`src/lib/routing.ts`). ⚠️ **Use the per-profile FOSSGIS servers, not the demo
|
||
one:** `router.project-osrm.org` hosts a SINGLE car dataset and **ignores the
|
||
profile in the URL**, so foot/bike/car all returned identical distance+time
|
||
(the "every transport option shows the same time" report). We now call
|
||
`https://routing.openstreetmap.de/routed-{foot|bike|car}/route/v1/{foot|bike|
|
||
driving}/…` (CORS `*`, direct first, `proxy.shakespeare.diy` proxy only as
|
||
fallback), request `steps=true` and expose `RouteStep[]` (verified 2026-09-27:
|
||
same trip = walk 13 min / bike 5 / drive 2). The demo server survives only as a
|
||
last-resort fallback and its result is flagged `approximate` ("driving
|
||
estimate") so we never pass driving numbers off as walking. Moto≈car is
|
||
labelled honestly; the foot summary also shows a ~10 km/h `skateTime` cruise
|
||
estimate ("~6 min on a board") next to the walking time. The dialog also
|
||
remembers the last mode (`localStorage: skatespots:route-mode`), shows an ETA
|
||
("arrive ~08:10 PM"), and each step row is **tappable** (pans the map + highlights)
|
||
with a "Whole route" re-fit button.
|
||
- ⚠️ `index.css` has a global mobile rule `button, [role=button] { min-height:
|
||
44px; min-width: 44px }` for touch targets. Any dense list of `<button>` rows
|
||
(like route steps) must add **`min-h-0`** or every row becomes a 44px slab —
|
||
a class beats the element selector, so `min-h-0` is enough.
|
||
- **Follow mode / start point / offline (2026-09-27):** the dialog also does
|
||
**follow mode** (explicit **Start** → `watchPosition` in `geolocation.ts`,
|
||
live dot, "NEXT · step N" banner with distance-to-turn + distance-to-go,
|
||
current step highlighted), a **start point** choice (My location / Choose on
|
||
map — tapping the Leaflet map sets it), and an **offline route cache**
|
||
(`src/lib/routeCache.ts`, downsamples the line to ≤250 points, keeps the last
|
||
8 destination+mode entries; the failed fetch falls back to it and shows
|
||
"Offline · saved route {age}"). Rules: **never auto-start the watcher**
|
||
(battery — user action only) and always `stop()` it on close/unmount; a
|
||
transient watch error (timeout/temporarily unavailable) must **not** tear down
|
||
the watcher — only `denied` does (headless CDP testing made this obvious:
|
||
moving the mocked position emits a transient error that recovered).
|
||
Still deliberately absent: voice guidance and re-routing if you leave the route.
|
||
- **Dialog layout (2026-09-27):** app-like flex column —
|
||
`flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden`: header, mode row,
|
||
"From" row and the **map** are `shrink-0` (map `h-[38vh] sm:h-[400px]`), the
|
||
middle (stat tiles + steps) is the only scroll region (`min-h-0 flex-1
|
||
overflow-y-auto`), and the **Start/Stop bar is pinned** at the bottom so it's
|
||
always reachable. ⚠️ Don't go back to a single `overflow-hidden` content box:
|
||
that made everything below the fold **unreachable** once the map grew. The
|
||
summary is three `StatTile`s (distance / time / arrive) + a detail line
|
||
(steps, driving-roads note, avg speed) and the map carries a metric
|
||
`L.control.scale`. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
||
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
|
||
modes. **No external map-app handoff anywhere** (product decision) and no
|
||
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from
|
||
`getLastLocation()` and skips the live GPS call entirely when that fix is
|
||
**< 2 min old**; the blocking "Finding your location…" overlay shows only when
|
||
there is no seed (with a seed it refines silently via a corner chip). The map
|
||
"Near Me" button also flips the sort to `proximity`.
|
||
- ⚠️ **`RouteDialog` had a total-failure bug (fixed 2026-09-27), don't regress:**
|
||
(1) its Leaflet map is inside Radix Dialog content, which mounts AFTER the
|
||
component's first effect pass, so a `mapDivRef.current` guard was null,
|
||
the init effect bailed, and **the map was never created** (no route line
|
||
ever appeared). The node is now held in **state** (`const [mapDiv,setMapDiv]`)
|
||
with a callback ref, and `mapDiv` is in the effect deps so it runs when the
|
||
node actually mounts.
|
||
(2) Even once created, the global `.leaflet-container { height: 100% }`
|
||
override in `index.css` **beats `h-64`** on the map element; against a
|
||
height-less parent it resolved to 0 (invisible map). Height now lives on the
|
||
wrapper (`<div className="relative h-64">`) and the map is `h-full`.
|
||
(3) On the map, Directions used to render the dialog INSIDE the spot `Sheet`
|
||
(two stacked Radix modals; the sheet covered the route). The map now owns
|
||
the dialog at page level (`routeTarget` state) and closes the sheet first.
|
||
- OSRM is called **directly** (`router.project-osrm.org` sends
|
||
`Access-Control-Allow-Origin: *`) with the `proxy.shakespeare.diy` CORS proxy
|
||
only as a fallback — faster, and origin/destination never leave OSRM unless the
|
||
direct call is blocked. Failures are distinguished: `Routing service busy`
|
||
(429/TooManyRequests), `No route found` (NoRoute / invalid coords), else
|
||
unreachable. Verify route rendering with `scripts/`-style CDP repros, not just
|
||
the smoke test (which only checks mount).
|
||
- **`Layout` is a router layout** — `AppRouter` wraps every route (except
|
||
`/amber-callback` and the `*` 404) in **one** `<Route element={<Layout/>}>`
|
||
with an `<Outlet/>`, so the chrome (header, nav, notifications, Sasquatch)
|
||
mounts **once** and survives navigation. Pages render **fragments** (`<>…</>`),
|
||
never `<Layout>`. The map is full-bleed via a route check (`isFullBleed` in
|
||
`Layout`), not a per-page `noPadding` prop. **Don't reintroduce per-page
|
||
`<Layout>` wrappers** — remounting the chrome re-runs its hooks (and with Amber
|
||
that means signer popups). Encrypted-on-mount hooks are also gated for
|
||
interactive signers (`staleTime/gcTime: Infinity`, `refetchOnMount: false`) —
|
||
see `useSyncedAppData` / `useStash`. **Read-state is local-only for interactive
|
||
signers**: `useReadState` skips the encrypted publish when
|
||
`isInteractiveSigner` is true, so merely opening the notification bell (which
|
||
marks read) no longer pops Amber twice (encrypt + sign). Interactive signers
|
||
only prompt for real signed actions.
|
||
- ⚠️ **`staleTime: Infinity` does NOT stop the FIRST decrypt (fixed
|
||
2026-09-27).** That was the "Amber pops on every screen" report: the
|
||
encrypted `useSyncedAppData` blobs (read-state, preferences) ran their
|
||
queryFn once on mount for each racing consumer, and relay responses landing
|
||
during navigation made it look per-page. `useSyncedAppData` now takes
|
||
**`interactiveNetwork` (default false)**: for an interactive signer the blob
|
||
is **local-only** — the query is not even enabled and `patch()` is a no-op —
|
||
so no decrypt on mount and no publish on a nav-tab change. localStorage is
|
||
the session source of truth. Only set `interactiveNetwork: true` if a feature
|
||
genuinely cannot work from localStorage. **Diagnostic recipe:** mock an
|
||
`x-amber` login by writing `localStorage['nostr:login']` before load, then
|
||
wrap `localStorage.setItem` to log writes of `amber_pending_operation`
|
||
(captures every prompt + a stack). Beware stale harness profiles — use a
|
||
fresh `--user-data-dir` per run or the ops array is from a previous run.
|
||
- ⚠️ **Don't make read-state mutators (or any hook they flow from) unstable
|
||
(fixed 2026-09-27).** `/newsletter` was stuck in an **infinite render loop**
|
||
(~100% CPU, React "Maximum update depth exceeded") because: `useSyncedAppData`
|
||
returned a **fresh object literal every render** → `useReadState.apply`
|
||
(dep `synced`) got a new identity → `markNewsletterRead`/`markAllRead` changed
|
||
every render → page effects depending on them re-ran after every render →
|
||
the mark set a **new state object** → re-render → loop. Same latent loop in
|
||
`NotificationBell` **while open**. Fixes (keep them): `useLocalStorage`'s
|
||
setter is **referentially stable** (ref + functional update), `useSyncedAppData`
|
||
returns a **memoized** object, `useReadState.apply` reads `synced`/`interactive`
|
||
from **refs** so it's stable, and `markAllRead`/`markNewsletterRead` are
|
||
**idempotent** (return the SAME object when the marker already covers `now`, so
|
||
React bails). Page effects that mark read on open use a **mount-once ref guard**
|
||
(`NewsletterPage`). Diagnose with CDP `Performance.getMetrics` `TaskDuration`
|
||
over ~4 s idle (a looping route burns ~4 s; a healthy one ~30–100 ms).
|
||
- **Newsletter publish/read relay rule:** issues (kind 30023,
|
||
`t=skatespotsnews`) and the subscription marker (kind 30078,
|
||
`d=skatespots:newsletter:subscription`) are **read via `useSkateRelays`
|
||
(`querySkateContent`)** — i.e. the community relay + the reader's relays, and
|
||
for logged-out visitors **only the community relay**. So they must be
|
||
**published to `getWriteRelays()`** (community relay + the author's relays),
|
||
NOT the default pool (`useNostrPublish`). The composer / backfill / subscribe
|
||
hook do this and fail loudly if every relay rejects. `useSpotChat` is the
|
||
reference pattern. (Before the fix, an admin whose own NIP-65 lacked
|
||
`goskatespots` would publish issues nobody could find.)
|
||
- **Nav parity (fixed 2026-09-27):** the desktop header is `max-w-[1700px]`
|
||
(wider than the page `container`'s 1400px) and nav items are `px-3` +
|
||
`whitespace-nowrap`; the nav keeps `min-w-0 overflow-x-auto scrollbar-hide`, so
|
||
**if it ever overflows it clips the TRAILING item — i.e. the "More" menu**
|
||
(this is exactly how "More" vanished when MARKETPLACE replaced MINTS). Keep the
|
||
header wide / padding tight; don't let the nav grow without room. The mobile
|
||
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
|
||
aren't hard-coded**, so Marketplace/Mints (and any future section) are always
|
||
reachable on mobile — don't re-hardcode only a subset.
|
||
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
|
||
paid in **sats only (Lightning or Cashu)** — no cards/accounts — sold
|
||
**locally, on request**: there is **no cart, no checkout, no shipping**. Buying
|
||
is a conversation: "Request an item" deep-links to a **DM with the owner**
|
||
(`/messages/<owner npub>`, owner = `PLATFORM_OWNER_PUBKEYS[0]`), the details
|
||
are agreed in chat, and sats change hands at handover. Don't add shipping
|
||
copy/rates or a cart without a decision to change that model. Registered in
|
||
`NAV_CATALOG` as id `app-shop` (label "App Shop", `ShoppingBag`), which is what
|
||
makes it appear in **both** the desktop "More" menu and the mobile burger menu
|
||
(the mobile menu appends catalog items not in `MOBILE_MENU_LISTED_HREFS`). It is
|
||
**intentionally empty**: no stock, no prices, no pre-orders and nothing for
|
||
sale. That is carried by the "COMING SOON" badge, the "IN THE WORKS / not for
|
||
sale yet" label on the teaser grid and the "sold locally, on request" hero
|
||
copy (the page deliberately has **no** stock list, no subscribe band and no
|
||
'nothing is for sale' note — removed on request). **Do not add prices/fake urgency/buy buttons until
|
||
there is real stock**; when items exist, model them as listings and take
|
||
Lightning/Cashu payment. The page reads the live stock strip as 0/None/Nothing
|
||
— keep it honest if that changes. It also serves two **free printables** (rendered INSIDE the 'Sticker packs'
|
||
entry of the 'In the works' grid — deliberately not a separate section, per the
|
||
owner: the free sticker + poster belong under their merch category)
|
||
(`public/images/print/skatespots-sticker.svg` 55×86 mm, `…-poster.svg` A3),
|
||
regenerated with `node scripts/make-print-art.mjs` (output is committed). Those
|
||
SVGs are self-contained: brand fonts + logo embedded as data URIs and a real
|
||
vector QR (verified decoding to https://skate-spots.org — if you regenerate,
|
||
re-verify the QR scans with the logo in the middle; error-correction level H).
|
||
- **Spot data quality (2026-09-27):** three cheap defences against junk pins
|
||
(test entries, airports, malls), because "the map looks endorsed" is the real
|
||
risk. (1) `CreateSpotPage` **requires ≥1 photo** (guard in `handleSubmit` +
|
||
the submit button is disabled) — a typo rarely has a photo. (2) The spot page
|
||
shows a **Community verified / Unverified** badge computed from signals only
|
||
*other* skaters can produce: community photos + `useSpotStatus` `reporterCount`
|
||
(kind 2259). The weather score was renamed **"Conditions · weather only"**
|
||
(in `SpotDetailPage` and the `SpotMap` popup) so it can't read as spot quality.
|
||
(3) `useNotASpotReport` = one-tap **NIP-56 (1984)** report tagged
|
||
`not-a-skate-spot` (NIP-56 `other` reason + our `L`/`l`/`t` taxonomy), used
|
||
from the spot's "More" menu and the shared `ReportDialog`.
|
||
- **Admin hide + review queue (2026-09-27):** `useAdminModeration` always READ
|
||
NIP-32 kind-1985 `hidden` labels, but **nothing published one** — the only
|
||
working hide was device-local (`useDeleteEntity` → `addLocalHidden`).
|
||
`useAdminHide` is the missing write side: kind 1985 + `['L',
|
||
'org.skatespots.moderation']` + `['l','hidden',NS]` + `['a',coord]`/`['e',id]`,
|
||
plus an optimistic local hide. `/review` (`AdminReviewPage`, **admin-only,
|
||
404 otherwise**) lists spots that trip cheap signals (suspicious title, name
|
||
≤3 chars, no photo, no description, no geohash) sorted by flag count, with
|
||
one-tap **Hide** or **Not a skate spot**; entry point is the admin-only
|
||
`AdminReviewCard` in Settings → Advanced. Hiding is **soft and reversible** —
|
||
the event stays on relays (honest for a censorship-resistant network); nothing
|
||
is deleted. `/review` is in the smoke-test route list.
|
||
- **Symmetric verdicts + richer signals (2026-09-27):** `MODERATION_LABEL_VERIFIED`
|
||
is the positive counterpart to `hidden`. `useAdminModeration` fetches both
|
||
labels in ONE query and exposes `verifiedCoords`/`verifiedIds`/`isVerified()`
|
||
(plus a local mirror via `addLocalConfirmed`/`getLocalConfirmed`), and
|
||
`useAdminVerify()` (same file as `useAdminHide`) publishes it. Using it, the
|
||
review queue can **Confirm** a spot — which clears it from the queue and shows
|
||
a "Verified by SkateSpots" badge on the spot page — instead of only ever
|
||
hiding things. Queue flags now also include **mapper trust** (`useBatchTrustScores`)
|
||
and **whether any skater has filed a status report** (`useBatchSpotStatus`
|
||
`reporterCount`) → 'mapper not in your web of trust' / 'no community signals'.
|
||
The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
|
||
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
|
||
'no community signals' only fires alongside another flag, so a brand-new real
|
||
spot isn't punished for being new. Community signals now include **other
|
||
skaters' photos**: `useBatchSpotPhotos` (`useSpotContributions.ts`) does ONE
|
||
`#a` query for every spot and ignores the mapper's own photos.
|
||
- **Verdicts are reversible — latest wins (2026-09-28):** `MODERATION_LABEL_CLEARED`
|
||
('cleared') is the neutral undo verdict. `useAdminModeration` no longer unions
|
||
labels; it keeps the **newest verdict per target** (by `created_at`, with
|
||
device-local verdicts overlaid by timestamp), so publishing `cleared`
|
||
supersedes an earlier `hidden`/`verified`. `useAdminClear()` publishes it.
|
||
The local mirror is now a **verdict map** (`skatespots_local_admin_verdicts_v1`,
|
||
key = coord or `e:<id>`, value `{verdict, at}`), migrating the legacy
|
||
hidden/confirmed arrays on read. `useSkateSpots(limit, { includeHidden: true })`
|
||
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
||
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
||
so both verbiage and verdicts are reversible in the UI.
|
||
- **Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):**
|
||
three gaps closed in one pass.
|
||
1. **Reports were a void.** `ReportDialog` publishes NIP-56 (1984) for spots/
|
||
events/tours/users/listings/comments, but the ONLY reader in the app was a
|
||
count on marketplace cards — an admin never saw a report. Now
|
||
**`useSpotReports`** batch-reads `kinds:[1984], '#e': [all spot ids]` in ONE
|
||
query (via `querySkateContent`), and the review queue ranks reported spots
|
||
ABOVE the heuristics (red `REPORTED: …` chip). `useNotASpotReport` now
|
||
publishes to **`getWriteRelays()`** (community relay + the reporter's) rather
|
||
than the default pool, because the admin reads from the community relay — the
|
||
same relay-parity rule as the newsletter and DMs. Verified end-to-end by
|
||
publishing a report from Node and seeing it surface in the queue.
|
||
2. **Duplicate guard** in `CreateSpotPage`: on submit, any existing spot within
|
||
**60 m** (geohash-decoded, ≤3 candidates) shows an inline warning listing them
|
||
with distances and links, plus "It's a different spot — publish anyway"
|
||
(`dupConfirmedRef` bypass). Designed for the clone-spam failure mode.
|
||
3. **Contributor feedback:** `useSkateSpot` no longer returns null for a hidden
|
||
spot when the viewer is its **author** — otherwise a moderated spot silently
|
||
404s for the person who made it. `SpotDetailPage` shows an author-only banner
|
||
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
|
||
labels, so there is nothing secret being revealed.
|
||
- **Review queue covers every content kind (2026-09-28):** `/review` now has tabs
|
||
for **spots / shops / events / tours / gear**. The hide/verify labels were
|
||
always kind-agnostic (`a`/`e` tags), so the work was: (1) add
|
||
`{ includeHidden }` (admin-tooling only) to `useSkateShops`, `useSkateEvents`,
|
||
`useTours` and `useMarketplaceItems` — same reason as spots; (2) generalise the
|
||
flags with **`contentFlags()`** in `src/lib/spotQuality.ts` (spot-specific
|
||
signals — trust/status/photos — stay in `spotFlags`); (3) one small component
|
||
per kind so only the OPEN tab queries (five hooks in one component would fire
|
||
five relay queries on every visit). Reports (`useSpotReports`) and trust
|
||
(`useBatchTrustScores`) are kind-agnostic; status/photo signals are spot-only.
|
||
Status/go-lab/crew queues are still out — profiles are less spam-prone and
|
||
hiding a crew hides a community.
|
||
- **Photo is recommended, not required (2026-09-28):** the hard block in
|
||
`CreateSpotPage` was removed — it punished an honest mapper with no photo on
|
||
hand. Publishing with no photo is allowed; the spot stays **Unverified**
|
||
(`SpotDetailPage` badge), the success toast says so, and the review queue's
|
||
'no photo' flag lets a human look later. The pre-submit checklist still nudges.
|
||
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
|
||
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
|
||
allLocations.length > 0`) and a new "No spots mapped yet — be the first" card
|
||
covers a genuinely empty map (`allLocations.length === 0`) with Add-spot +
|
||
Near-me CTAs. ⚠️ Don't key it off `mapSpots`: that is derived FROM
|
||
`filteredSpots`, so it is 0 whenever filters hide everything.
|
||
- **Report inbox + bulk undo + crews/Lab moderation (2026-09-28):** finishing the
|
||
moderation gaps. `/review` now defaults to a **Reports** tab: `useRecentReports`
|
||
fetches recent NIP-56 (1984) reports and **resolves the referenced event by id**
|
||
(one extra `ids:` query) so ANY report is actionable — including content that
|
||
isn't flagged and comments/profiles that never appear in the heuristic queue.
|
||
`reportTargetMeta(kind)` maps a kind to its label + route for the "Look at it"
|
||
link. The queue also has **crews** and **lab** tabs (added `includeHidden` to
|
||
`useCrews`/`useLabProfiles`); because hiding those removes a community or a
|
||
person's listing rather than one pin, their Hide button goes through an
|
||
`AlertDialog` confirmation (`confirmHide`/`confirmHideNote` on `QueueList`).
|
||
When a tab holds more than one verdict, a toolbar offers **Undo all N** (also
|
||
AlertDialog-gated, and it warns that each undo is a separate label publish —
|
||
one Amber approval each). Verified in the browser: 8 tabs render, the inbox
|
||
shows and resolves a real report, and the Spots tab shows the bulk-undo toolbar.
|
||
- **App listing submitted (2026-09-28):** SkateSpots was added to the community
|
||
**nostrapps.com** directory by PR. Recipe (ngit has an active remote-signer
|
||
session, so this works non-interactively — no browser needed):
|
||
`git clone https://relay.ngit.dev/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrapps-com.git`
|
||
→ edit `apps.toml` (one `[app-id]` table; keys seen in the file: name,
|
||
categories, description, gallery?, features, npub, platforms, source?, thumb,
|
||
url) → `git remote add nostrapps nostr://<owner-npub>/nostrapps-com` →
|
||
`git push -o 'title=…' -o 'description=…' -u nostrapps <branch>:pr/<branch>`.
|
||
Opening a PR = pushing a `pr/`-prefixed branch; `ngit pr list|view|apply`.
|
||
**Our PR: `bbb27a91…` "Add SkateSpots", open.** The gallery images live at
|
||
`public/images/screenshots/{map,spot,marketplace}.png` (1280×800, quantised —
|
||
captured with headless Chromium; regenerate with a CDP harness if the UI
|
||
changes materially).
|
||
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
|
||
free handles so a new skater has a human name from minute one. The registry is
|
||
the Netlify Function **`netlify/functions/nip05.mjs`**
|
||
(`/.well-known/nostr.json` + `/api/nip05/{check,claim,release}`), backed by
|
||
**Netlify Blobs** (`@netlify/blobs`, store `nip05`, one key per name) — no new
|
||
vendor, no new secret. Rules: **non-gating** (a handle is a badge, never a key
|
||
— the app works without one), **proof by signature** (a claim carries a NIP-98
|
||
kind-27235 event in the `x-nip05-auth` header, signed by the pubkey that will
|
||
own the name), and `onlyIfNew` writes so two claims can't clobber each other.
|
||
Platform names are seeded in code (`skate-spots`/`skatespots` → owner pubkey,
|
||
`skater` → the second admin `caba497f…`) and cannot be claimed/released by
|
||
users; a small `RESERVED` list blocks impersonation words. Any admin can wear
|
||
a seeded handle by typing it in Edit Profile → NIP-05 (it verifies against the
|
||
seed; no claim needed). Client: `src/lib/skateNip05.ts` (shared validation,
|
||
`deriveSkateNip05Handle`, `parseSkateNip05`, `skateApiBase()` for the native
|
||
absolute-URL rule), `src/hooks/useSkateNip05.ts` (check/claim/release), and
|
||
`src/components/SkateNip05Input.tsx` (debounced availability + Claim).
|
||
Wired into **signup** (`SignupDialog` profile step adds a "SkateSpots name"
|
||
field, prefilled from the display name until edited; the handle is claimed
|
||
best-effort *before* the kind-0 publish, and **a failure never blocks signup** —
|
||
a profile only advertises `nip05` once the registry confirms it) and into
|
||
**Edit Profile** (`Nip05Field` gains a "claim a free handle" block).
|
||
`Settings → NIP-05` links to it. ⚠️ `config.path` DOES take precedence over the
|
||
`/*` SPA redirect for `/.well-known/*` (verified on a draft deploy). Test
|
||
functions with a free draft (`netlify deploy --dir=dist`), not a prod deploy.
|
||
Verified end-to-end on a draft (2026-09-28): claim → resolves → a second key
|
||
gets 409 → release → gone.
|
||
- **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a
|
||
relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow
|
||
**every** relay error and return `[]`, so a dead relay looked like "no spots"
|
||
AND overwrote good cached data with an empty list (which the persister then
|
||
saved). Now:
|
||
- `querySkateContent` / `queryCanonical` take an opt-in **`requireAny`** that
|
||
throws when NO relay answered. Only the map's own feeds pass it
|
||
(`useSkateSpots`, `useShops`, `useMarketplaceItems`, `useTours`); the other
|
||
~75 call sites keep the old behaviour, deliberately.
|
||
- `useSkateSpots` / `useSkateSpot` catch that error and fall back to the
|
||
**Dexie spot cache** (`lib/offlineCache`), so the map still works at the
|
||
park; they only re-throw when nothing is cached (UI shows an offline card).
|
||
- `shouldDehydrateQuery` **never persists an empty array** — otherwise one
|
||
blip would wipe the persisted map for the next 24h.
|
||
- `MapPage` shows "Offline · showing saved spots" + retry, and a "Can't reach
|
||
the relays" card instead of the false "No spots mapped yet". Reachability
|
||
reuses the existing **`useRelayStatus(url)`** (`lib/relayProbe`, a real
|
||
Nostr handshake) plus `useOnlineStatus` — NOT `navigator.onLine` alone,
|
||
which is true on a captive-portal Wi-Fi.
|
||
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
|
||
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
|
||
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),
|
||
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
|
||
worker: a SW only helps the web build, while the Android app runs inside
|
||
Capacitor's WebView where a SW never sees tile requests. Works on both.
|
||
Still open: offline **app shell** (SW) — unnecessary for native, deferred for
|
||
web.
|
||
- **Photo redundancy (2026-09-28):** `useUploadFile` now uses **two** Blossom
|
||
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
|
||
`BlossomUploader.upload` signs ONE auth event then does
|
||
`Promise.any(servers.map(fetch))` — it **PUTs to every server in parallel**, so
|
||
the old 3-host list was silently **triple-uploading every photo** (the code
|
||
comment claiming "tries them in order" was wrong). Two hosts = less client
|
||
data, still a single signature (no extra Amber prompt). The returned tags gain
|
||
`['fallback', '<mirror-host>/<sha256>']`, and **`lib/imageFallback.ts` +
|
||
`ImageWithFallback` retry the same hash on the other known Blossom host on
|
||
error** — one choke point covers every photo/logo call site (~20), so a dead
|
||
host costs a moment, not a blank spot. Blossom is content-addressed, so the
|
||
mirror URL is derived from the sha256 in the path (handles `/<sha>` and
|
||
`/<sha>.<ext>`).
|
||
- **AI proxy on our own host (`ai-proxy/`, 2026-09-28):** the same proxy the
|
||
Netlify Function runs, rebuilt as a standalone **Bun** service on the CVM host so
|
||
AI calls stop spending Netlify compute/bandwidth credits and the Routstr key
|
||
lives on our box. `ai-proxy/server.ts` (`Bun.serve` on 127.0.0.1:8787; NIP-98 +
|
||
Pro gate + model allowlist + per-IP throttle), deployed with **`npm run deploy:ai`**
|
||
(`scripts/deploy-ai-proxy.sh`) to `/home/ubuntu/skatespots-ai`, systemd unit
|
||
`skatespots-ai`, type-checked by `ai-proxy/tsconfig.json` (`typecheck:ai`, wired
|
||
into `npm test`); `ai-proxy/` is eslint-ignored like `cvm-server/`. Env
|
||
(`SASQUATCH_*`) copied from Netlify env to `~/skatespots-ai/.env` (600);
|
||
**verified live** (`POST /api/ai/status` → the real Routstr balance). The client
|
||
picks the base in **one place — `src/lib/sasquatchAi.ts`** (`sasquatchAiBase()`,
|
||
also used for the NIP-98 `u` tag). **Live (2026-09-28):** `ai.skate-spots.org`
|
||
→ the CVM host (an `A` record in **Netlify DNS**; ⚠️ the API silently *dropped
|
||
the first create* — verify the NS actually serves a new record), TLS via
|
||
**Caddy** (auto Let's Encrypt, installed on the host). Production builds now
|
||
default to it (`import.meta.env.PROD` → `https://ai.skate-spots.org/api/ai`);
|
||
dev uses local `/api/ai`. ⚠️ `netlify/functions/ai.mjs` is now a **fallback
|
||
only** — revert with `VITE_SASQUATCH_AI_BASE=https://skate-spots.org/api/ai`.
|
||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||
https://mintradar.org/api/mints/:splat 200`), dev via `server.proxy` in
|
||
`vite.config.ts`. Data is unofficial/rate-limited; adding a mint is native-only.
|
||
⚠️ **Relative `/api/*` paths don't work in the native app** (fixed 2026-09-27):
|
||
the shell is served from `https://localhost`, so `/api/mints/known` hits the
|
||
LOCAL asset server and returns `index.html` → `Unexpected token '<' … not valid
|
||
JSON`. `useMintDirectory` now uses `https://skate-spots.org/api/mints/known`
|
||
via **CapacitorHttp** on native (core plugin, no CORS) with a plain-fetch
|
||
fallback, and turns an HTML body into a friendly message. Same pattern already
|
||
used by `sasquatchBrain.ts` / `useSasquatchAiStatus` (`Capacitor.isNativePlatform()
|
||
? 'https://skate-spots.org/api/…' : '/api/…'`) — follow it for any new `/api`
|
||
call, and never let a raw JSON parse error reach the UI.
|
||
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
|
||
retries, then one full reload per session, so a chunk-load failure self-heals
|
||
instead of showing the error boundary.
|
||
- **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**,
|
||
**101 tests**, build ok. The gate also type-checks the CVM server
|
||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.5.4**.
|
||
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||
`lib/trustFilter.ts`) and unused hooks/functions deleted — no blanket
|
||
disables. Keep it at zero — don't reintroduce any.
|
||
- ⚠️ Do **not** mass-fix `no-unused-vars` by name or blanket rename — shadowed
|
||
names and typed shorthand destructuring make it unsafe. Fix position-precise.
|
||
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or
|
||
suppress instead.
|
||
- **Untracked local junk — do NOT commit** (add to `.gitignore`, not the repo):
|
||
`assets/`, `resources/`, `/bun.lock`, `cvm-server/bun.lock`,
|
||
`cvm-server/package.json` (local dev copy of the tracked `package.cvm.json`),
|
||
`*.bak`.
|
||
(`cvm-server/` *source* IS tracked — only its locks/backups are junk.) Watch
|
||
out: `git add -A` in the repo root sweeps these in.
|
||
- **Never `git add -A` from the repo root** without checking `git status` first.
|
||
|
||
## Local Dev Environment, Build & Release (project-specific — read first)
|
||
|
||
Machine-specific, verified operational notes. Use these instead of re-deriving workflows from scratch.
|
||
|
||
### Where things are
|
||
- **Dev root / repo**: `/home/sovereignsk8/SkateSpots` — the clone whose `origin` is the `nostr://` remote.
|
||
- **origin**: `nostr://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx/git.shakespeare.diy/SkateSpots` (browsable at nostrhub.io).
|
||
- **Live site**: https://skate-spots.org — served by **Netlify**, deployed via **shakespeare.diy** (which also hosts the git server).
|
||
- **Backups**: `~/.backups/` (nested clone, old lockfiles, keystore copies).
|
||
|
||
### Toolchain (already installed on this machine)
|
||
Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngit` 3.0.3 (`~/.cargo/bin`) · `zsp` (`~/.local/bin`).
|
||
|
||
### Run & validate
|
||
- `npm run dev` → http://localhost:8080. Note the script runs `npm i` first (see lockfile caveat).
|
||
- `npm test` = `npm i` + `tsc` + `eslint` + `vitest` + `vite build`. **Must pass before committing.**
|
||
|
||
### ⚠️ "This page slammed" on *some* routes in dev (dep optimizer cache)
|
||
Symptom: in the running dev server a few lazy routes (e.g. `/settings`, `/stash`, `/collections`, `/stats`) show the "This page slammed" error boundary while others work. The browser console shows `Failed to fetch dynamically imported module` and the network tab shows **504** for `node_modules/.vite/deps/*.js?v=…`.
|
||
- **Cause**: Vite's dependency optimizer cache (`node_modules/.vite/deps`) went missing/was invalidated while the dev server stayed up (long-running servers re-optimize whenever the lockfile changes — and every `npm i`, which `dev`/`test`/`build` run, touches `package-lock.json`). The server keeps serving stale `?v=` hashes → 504 → `React.lazy` rejects.
|
||
- **Fix**: restart the dev server (`rm -rf node_modules/.vite` then `npm run dev`) and hard-refresh the browser. `npm i` / `vite build` / `vitest` alone do **not** wipe the cache — it's the long-running server's re-optimize that races.
|
||
- **Resilience**: all routes use `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) instead of bare `lazy()`. On a chunk-load failure it retries once, then performs a single full page reload per session, so a transient optimizer/deploy hiccup self-heals instead of dead-ending on the error boundary.
|
||
|
||
### ⚠️ Lockfile caveat (important)
|
||
The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped of `dependencies`/`bin` metadata, so transitive packages were silently missing. Repaired in `eb23ebc` (654 → 902 packages).
|
||
- **Regression symptoms**: `npx cap` → "could not determine executable to run"; `tsc` → "Module '@nostrify/nostrify' has no exported member NostrEvent"; Vite → "Could not resolve @std/encoding/hex".
|
||
- **Do NOT regenerate the lock blindly** — a fresh resolve bumps ~300 packages incl. major `@babel` 7→8 and `esbuild` 0.25→0.28. Prefer `npm ci`. If broken, repair each entry's `dependencies`/`bin` from the installed `node_modules/*/package.json`, then `npm install` to fill only the gaps.
|
||
|
||
### Android release build
|
||
- `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`.
|
||
- **Versioning**: `package.json` `version` is the single source of truth; `scripts/sync-android-version.mjs` syncs it to `versionName`/`versionCode` (e.g. `1.4.5` → `10405`) during the build. Bump **only** `package.json`.
|
||
- **Signing**: secrets live in the gitignored `keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`/home/sovereignsk8/skatespots-release.jks`** (alias `skatespots`). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`.
|
||
- `android/` is **generated and gitignored**; recreate with `npm run android:init`.
|
||
- Preflight: `cd android && ./gradlew :app:signingReport` confirms the signing config resolves.
|
||
|
||
### Version bump + release notes
|
||
1. Bump `package.json` `version` (Zapstore rejects an already-published version).
|
||
2. Update the **top** section of `release-notes.txt` (format: `vX.Y.Z`, blank line, notes, then `---` before older versions).
|
||
|
||
### ⚠️ Production-bundle runtime ≠ dev (read before touching vite.config)
|
||
|
||
`vite build` only bundles; it never *executes* the output, and dev mode doesn't
|
||
either. A build can compile cleanly and still crash at runtime — that's how the
|
||
old custom `manualChunks()` shipped a white screen to production:
|
||
`ReferenceError: Cannot access 'X' before initialization` (cross-chunk circular
|
||
import TDZ, first in `vendor-charts`, then `vendor-bitcoin`). It only affected
|
||
the **web** build; the Capacitor build uses `inlineDynamicImports` (single
|
||
bundle) and was unaffected.
|
||
|
||
Rules:
|
||
- **Do not re-introduce custom `manualChunks`** without a boot test. Rollup's
|
||
default splitting is cycle-safe.
|
||
- `scripts/smoke-test.mjs` boots the built `dist/` in headless Chromium and
|
||
fails if `#root` doesn't mount **or** if any route shows the "This page
|
||
slammed" error boundary. It walks each route **in the URL hash** (HashRouter!):
|
||
`/`, `/marketplace`, `/leaderboards`, `/bounties`, `/settings`, `/stash`,
|
||
`/collections`, `/stats`, `/messages/group/abc123`. `scripts/auto-deploy.sh`
|
||
runs it before every deploy and **aborts the deploy** if it fails.
|
||
- Any time the deploy output isn't verified by the smoke test, run
|
||
`node scripts/smoke-test.mjs` manually before shipping.
|
||
|
||
### Deploy
|
||
- **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`.
|
||
- ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately records nothing, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately.
|
||
- ⚠️ Shakespeare's "Deploy" rebuilds **its own workspace**, which is NOT updated by git pushes — so pushing to the repo does not change what Shakespeare deploys. Prefer the auto-deploy timer (or drag `dist/` to Netlify).
|
||
- **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab.
|
||
- **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml`
|
||
- `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`.
|
||
- No NIP-07 browser extension is installed, so `SIGN_WITH=browser` fails. Use `bunker://…` (Amber) or `nsec1…`.
|
||
- `zapstore.yaml` sets `release_source` → local APK, `release_notes` → `release-notes.txt`, `icon` → `public/images/icon-1024.png`.
|
||
- Dry run (no upload): `SIGN_WITH='<signer>' zsp publish --offline zapstore.yaml`.
|
||
|
||
### Relay archive (dataset backup)
|
||
- `scripts/archive-relay.mjs` snapshots **the app's own content** off the
|
||
community relay to gzipped JSONL of the raw signed events (restorable to any
|
||
relay, signatures intact). Default dir `~/.backups/relay` (override with
|
||
`SKATESPOTS_ARCHIVE_DIR`), keeps the last 12, and writes `manifest-*.json` +
|
||
`latest.json` (counts by kind, author count, sha256, byte size).
|
||
- ⚠️ **`goskatespots.nostr1.com` is a PUBLIC relay with general traffic**
|
||
(verified 2026-09-28): 15.7k kind-0 profiles, mostly non-skaters; 2k kind-30078
|
||
app-data (Armada/Plebs/Amethyst); 379 kind-30023 articles (Yakihonne); 408
|
||
NIP-99 listings (Shopstr). So the archive is deliberately **scoped** — exclusive
|
||
kinds wholesale (30818/30017/30823/35xxx/…), shared kinds only with the app's
|
||
own tags (`t=skateboarding` for events + listings, `t=skatespots` collections,
|
||
`t=skatespotsnews` newsletters, `d=skatespots*` app data), `org.skatespots.*`
|
||
labels, reports/RSVPs that reference archived content, and kind-0 profiles for
|
||
archived authors only. Result: ~114 events / ~63 KiB. **Never archive the whole
|
||
relay** — it is strangers' data, and a restore would republish it.
|
||
- ⚠️ **Query ONE kind at a time, and stop on "no new events", not on a short
|
||
page.** A single filter covering many kinds comes back truncated (once kept
|
||
only 3 of 54 spots), and `querySync` can resolve early on a large page (lost
|
||
~150 of 3.3k profiles). Paginate with `until` until a page adds nothing.
|
||
- `scripts/restore-relay.mjs <archive.jsonl.gz> --dry-run` re-verifies every
|
||
signature; `--to wss://relay` republishes. There is **no default target** —
|
||
restoring is deliberate, and the mistake to avoid is publishing a snapshot back
|
||
to the live relay. Use `scripts/drill-relay.mjs` as the throwaway target.
|
||
- Offsite: `scripts/backup-relay.sh` runs the archive, then `restic backup` +
|
||
`forget --prune` **if** `~/.config/skatespots/restic.env` exists (template:
|
||
`scripts/restic.env.example`). **Configured (2026-09-28):** the **CVM host over
|
||
SFTP**, repo `sftp:skatespots-cvm:/home/ubuntu/skatespots-backups/relay`, via an
|
||
`~/.ssh/config` alias `skatespots-cvm` (HostName/User/IdentityFile from
|
||
`~/.config/skatespots/cvm.env`) so no key/host lives in tracked files.
|
||
`~/.config/skatespots/restic.env` (600, outside the repo) holds the repo URL +
|
||
the generated `RESTIC_PASSWORD` — **that password is required to restore; keep a
|
||
copy in a password manager**. A second offsite is supported: put another repo
|
||
(e.g. Cloudflare R2 / Backblaze B2) in **`~/.config/skatespots/restic-2.env`**
|
||
and `backup-relay.sh` pushes to **both** — true 3-2-1 (local + CVM + object
|
||
storage). Verified 2026-09-28 with a throwaway second repo.
|
||
- ⚠️ **Don't run restic under a restrictive `umask`.** restic creates its cache
|
||
dirs from the umask, and a mode-600 dir has no execute bit → "permission
|
||
denied" on `~/.cache/restic/<repo>/version`. If it happens:
|
||
`find ~/.cache/restic -type d ! -perm -u+x -exec chmod u+rwx {} +`.
|
||
- **Restore drill (proven 2026-09-28):** `node scripts/drill-relay.mjs [port]`
|
||
starts a throwaway in-memory NIP-01 relay (`ws`, no persistence); then
|
||
`node scripts/restore-relay.mjs <archive> --to ws://localhost:<port>` and query
|
||
it back. Latest run: **114/114 events restored, 0 missing**. Re-run after any
|
||
archive-format change, or quarterly.
|
||
- `restic check --read-data-subset=10%` was clean after the first offsite push
|
||
(do this monthly to catch bitrot, not just existence).
|
||
- Weekly systemd user timer: `scripts/install-archive-timer.sh` copies
|
||
`scripts/systemd/skatespots-archive.{service,timer}` into `~/.config/systemd/user`
|
||
and enables them. Logs: `journalctl --user -u skatespots-archive`.
|
||
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
|
||
are **skipped by the deploy timer** → no Netlify credits spent.
|
||
|
||
### Git / ngit publishing
|
||
- Push: `git push origin main` (handled by `git-remote-nostr`; publishes a signed repo-state event).
|
||
- Login (once): `ngit account login --interactive` → scan the QR in Amber ("Add a new application → Connect app"). Plain `ngit account login` errors; the `--interactive` flag is required.
|
||
- The NIP-65 write list contains a dead relay (`wss://wotr.relatr.xyz`), which makes `ngit repo edit` fail unless run with `--repo-relay-only`. Local git config `nostr.repo-relay-only true` is set as a workaround. Fully fix by removing that relay in the app (Settings → Relays). **Done in defaults 2026-09-28**: removed from `App.tsx` `defaultConfig`, `NostrSync` `NEW_USER_RELAYS`, `NostrProvider` `DEFAULT_RELAYS`, `TestApp`, and the CVM `DEPLOY.md` example. ⚠️ Existing accounts keep it in their **stored** config until they remove it themselves (no migration applied).
|
||
- `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`.
|
||
|
||
### Secrets — never commit or paste
|
||
`keystore.properties`, `*.jks`, `.env*`, and `android/` are gitignored. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager.
|
||
|
||
## Project Structure
|
||
|
||
- `/skills/`: Specialized implementation guides for specific features - each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. See the Skills Directory section below for details on available skills.
|
||
- `/src/components/`: UI components including NostrProvider for Nostr integration
|
||
- `/src/components/ui/`: shadcn/ui components (48+ components available)
|
||
- `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.)
|
||
- Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments
|
||
- `/src/hooks/`: Custom hooks including:
|
||
- `useNostr`: Core Nostr protocol integration
|
||
- `useAuthor`: Fetch user profile data by pubkey
|
||
- `useCurrentUser`: Get currently logged-in user
|
||
- `useNostrPublish`: Publish events to Nostr
|
||
- `useUploadFile`: Upload files via Blossom servers
|
||
- `useAppContext`: Access global app configuration
|
||
- `useTheme`: Theme management
|
||
- `useToast`: Toast notifications
|
||
- `useLocalStorage`: Persistent local storage
|
||
- `useLoggedInAccounts`: Manage multiple accounts
|
||
- `useLoginActions`: Authentication actions
|
||
- `useIsMobile`: Responsive design helper
|
||
- `useZaps`: Lightning zap functionality with payment processing
|
||
- `useWallet`: Unified wallet detection (WebLN + NWC)
|
||
- `useNWC`: Nostr Wallet Connect connection management
|
||
- `useNWCContext`: Access NWC context provider
|
||
- `/src/pages/`: ~50 route pages (MapPage is the homepage; see `AppRouter.tsx`)
|
||
- `/src/lib/`: shared logic — `types.ts` (custom kind constants), `lazyWithRetry.ts`, `queryPersister.ts`, helpers
|
||
- `/src/contexts/`: React context providers (AppContext, LanguageContext, NWCContext)
|
||
- `NIP.md`: **source of truth** for every custom/adopted Nostr kind and its schema
|
||
- `AGENTS.md` (this file): the only cross-session memory — keep it current
|
||
- `UX_AUDIT.md`, `GROUP_MESSAGING.md`, `ESCROW.md`: feature decision/backlog docs
|
||
- `/src/test/`: Testing utilities including TestApp component
|
||
- `/public/`: Static assets
|
||
- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application)
|
||
- `AppRouter.tsx`: React Router configuration
|
||
|
||
**CRITICAL**: Always read the files mentioned above before making changes, as they contain important setup and configuration for the application. Never directly write to these files without first reading their contents.
|
||
|
||
## UI Components
|
||
|
||
The project uses shadcn/ui components located in `@/components/ui`. These are unstyled, accessible components built with Radix UI and styled with Tailwind CSS. Available components include:
|
||
|
||
- **Accordion**: Vertically collapsing content panels
|
||
- **Alert**: Displays important messages to users
|
||
- **AlertDialog**: Modal dialog for critical actions requiring confirmation
|
||
- **AspectRatio**: Maintains consistent width-to-height ratio
|
||
- **Avatar**: User profile pictures with fallback support
|
||
- **Badge**: Small status descriptors for UI elements
|
||
- **Breadcrumb**: Navigation aid showing current location in hierarchy
|
||
- **Button**: Customizable button with multiple variants and sizes
|
||
- **Calendar**: Date picker component
|
||
- **Card**: Container with header, content, and footer sections
|
||
- **Carousel**: Slideshow for cycling through elements
|
||
- **Chart**: Data visualization component
|
||
- **Checkbox**: Selectable input element
|
||
- **Collapsible**: Toggle for showing/hiding content
|
||
- **Command**: Command palette for keyboard-first interfaces
|
||
- **ContextMenu**: Right-click menu component
|
||
- **Dialog**: Modal window overlay
|
||
- **Drawer**: Side-sliding panel (using vaul)
|
||
- **DropdownMenu**: Menu that appears from a trigger element
|
||
- **Form**: Form validation and submission handling
|
||
- **HoverCard**: Card that appears when hovering over an element
|
||
- **InputOTP**: One-time password input field
|
||
- **Input**: Text input field
|
||
- **Label**: Accessible form labels
|
||
- **Menubar**: Horizontal menu with dropdowns
|
||
- **NavigationMenu**: Accessible navigation component
|
||
- **Pagination**: Controls for navigating between pages
|
||
- **Popover**: Floating content triggered by a button
|
||
- **Progress**: Progress indicator
|
||
- **RadioGroup**: Group of radio inputs
|
||
- **Resizable**: Resizable panels and interfaces
|
||
- **ScrollArea**: Scrollable container with custom scrollbars
|
||
- **Select**: Dropdown selection component
|
||
- **Separator**: Visual divider between content
|
||
- **Sheet**: Side-anchored dialog component
|
||
- **Sidebar**: Navigation sidebar component
|
||
- **Skeleton**: Loading placeholder
|
||
- **Slider**: Input for selecting a value from a range
|
||
- **Switch**: Toggle switch control
|
||
- **Table**: Data table with headers and rows
|
||
- **Tabs**: Tabbed interface component
|
||
- **Textarea**: Multi-line text input
|
||
- **Toast**: Toast notification component
|
||
- **ToggleGroup**: Group of toggle buttons
|
||
- **Toggle**: Two-state button
|
||
- **Tooltip**: Informational text that appears on hover
|
||
|
||
These components follow a consistent pattern using React's `forwardRef` and use the `cn()` utility for class name merging. Many are built on Radix UI primitives for accessibility and customized with Tailwind CSS.
|
||
|
||
## Skills Directory
|
||
|
||
The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.**
|
||
|
||
### Wallet Skills
|
||
|
||
- **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications.
|
||
|
||
- **`skills/lightning-wallet/SKILL.md`**: Use when implementing a standalone Lightning wallet using Cashu mints - provides complete patterns for minting (receiving Lightning payments), melting (sending Lightning payments), mint management, transaction history, and integrating with Lightning addresses and invoices. Read when building a Lightning wallet with Cashu mints, implementing Lightning payment flows (send/receive), managing Cashu mints, processing Lightning invoices, or integrating Lightning address functionality with a custodial wallet.
|
||
|
||
- **`skills/cashu-wallet/SKILL.md`**: Use when implementing Cashu token wallet functionality - provides complete patterns for sending and receiving Cashu tokens, token QR codes, automatic mint management, and integrating with Lightning wallet operations. Read when building a Cashu token wallet, implementing token send/receive operations, handling Cashu token transfers, managing token QR codes, or building a full-featured wallet that combines both Lightning and token operations.
|
||
|
||
- **`skills/nostr-wallet-connect/SKILL.md`**: Use when implementing Nostr Wallet Connect (NIP-47) wallet service - provides complete patterns for generating NWC connection strings, publishing info events, listening for requests, processing wallet commands, and sending encrypted responses. Client-side patterns included for reference. Read when building wallet services that expose Lightning functionality via NWC, generating NWC connection URIs for clients, implementing NIP-47 wallet service functionality, or handling encrypted wallet communication over Nostr relays.
|
||
|
||
### Lightning Integration Skills
|
||
|
||
- **`skills/lightning-address/SKILL.md`**: Use when implementing Lightning address functionality - provides complete patterns for resolving Lightning addresses to invoices, generating invoices from addresses, displaying Lightning addresses in UI, and integrating with QR codes. Read when adding Lightning address support, implementing LNURL-pay protocol, generating invoices from Lightning addresses, displaying Lightning addresses in wallet UIs, or integrating Lightning addresses with QR code functionality.
|
||
|
||
- **`skills/lightning-invoice-decoder/SKILL.md`**: Use when implementing BOLT11 Lightning invoice decoding - provides complete patterns for decoding invoice amounts, validating invoice format, and extracting satoshi values from Lightning invoices. Read when adding Lightning invoice parsing, validating BOLT11 invoices, extracting payment amounts from invoices, or implementing Lightning payment flows.
|
||
|
||
- **`skills/npub-cash-address/SKILL.md`**: Use when implementing npub.cash static Lightning address functionality - provides complete patterns for obtaining static Lightning addresses (username@npubx.cash), managing npub.cash account settings, syncing quotes from npub.cash, and integrating with Lightning wallets. Read when building Lightning wallet features that need static addresses, implementing npub.cash integration, managing user Lightning addresses, or syncing quotes from npub.cash to Cashu wallets.
|
||
|
||
### Utility Skills
|
||
|
||
- **`skills/qr-code-generator/SKILL.md`**: Use when implementing QR code generation - provides complete patterns for generating QR codes from text/URLs, displaying QR codes in modals, and customizing QR code appearance. Read when adding QR code generation functionality, displaying QR codes for addresses/invoices, or generating QR codes for sharing content.
|
||
|
||
- **`skills/qr-code-scanner/SKILL.md`**: Use when implementing QR code scanning - provides complete patterns for camera-based scanning, classifying scanned content (Bitcoin addresses, Lightning invoices, npubs, Cashu tokens), handling camera permissions, and paste from clipboard functionality. Read when adding QR code scanning functionality, implementing camera-based scanning, classifying scanned QR content, or handling QR code input from users.
|
||
|
||
- **`skills/exchange-rates/SKILL.md`**: Use when implementing exchange rate functionality - provides complete patterns for fetching BTC/fiat exchange rates from Coinbase API, caching strategies, conversion utilities, and React hooks for displaying rates in UI. Read when adding exchange rate display, converting between BTC and fiat currencies, showing USD equivalents for Bitcoin amounts, or implementing currency conversion features.
|
||
|
||
### Steganography Skills
|
||
|
||
- **`skills/emoji-encoder/SKILL.md`**: Use when implementing steganographic text encoding using Unicode Variation Selectors - allows encoding any message invisibly into emojis, letters, or any characters using Unicode Variation Selectors (U+FE00-U+FE0F and U+E0100-U+E01EF). Read when adding steganographic encoding, hiding data in plain text, encoding messages into emojis/characters, or implementing invisible data encoding using Unicode Variation Selectors.
|
||
|
||
- **`skills/lsb-steganography/SKILL.md`**: Use when implementing Least Significant Bit (LSB) steganography - allows hiding images or text within other images by encoding data in the least significant bits of pixel color channels. Read when adding steganographic image encoding, hiding images within images, encoding text into images, implementing invisible data storage in images, or building privacy-preserving image sharing features.
|
||
|
||
## System Prompt Management
|
||
|
||
`AGENTS.md` is the assistant's system prompt **and its only persistent memory**.
|
||
Future sessions start with nothing but this file, so treat it as the project's
|
||
long-term memory:
|
||
|
||
1. Edit `AGENTS.md` directly when you learn something important or when a
|
||
described detail has changed.
|
||
2. Changes take effect in the next session — so record now, not "later".
|
||
3. Re-read the relevant section before relying on it; if it's out of date, fix it
|
||
in the same session rather than working around it.
|
||
|
||
## Credits — no MKStack / Shakespeare (owner decision, 2026-09-28)
|
||
|
||
⚠️ This used to say: "include 'Vibed with MKStack' somewhere in the UI, linked to
|
||
https://soapbox.pub/mkstack". **The owner removed it on request — do NOT re-add
|
||
it.** `/about` → Credits no longer mentions MKStack or Shakespeare, and the
|
||
"Nostr" credit links to the protocol repo
|
||
(`https://github.com/nostr-protocol/nostr`), not nostr.com.
|
||
|
||
The credits should read (keep this shape when editing): **Nostr** (protocol) ·
|
||
**Cordn** (crew chat, cordn.net) · **Cashu** (ecash, cashu.space) · **NWC**
|
||
(docs.nwc.dev) · **Amber** (Android signer) · **BTCMap** (btcmap.org) · **Angor**
|
||
· **Zapstore** · **ContextVM**. (`useShakespeare.ts`, `proxy.shakespeare.diy` and
|
||
similar are internal endpoints/hook names, not credits — leave them.)
|
||
|
||
## Nostr Protocol Integration
|
||
|
||
This project comes with custom hooks for querying and publishing events on the Nostr network.
|
||
|
||
### Nostr Implementation Guidelines
|
||
|
||
- Always check the full list of existing NIPs before implementing any Nostr features to see what kinds are currently in use across all NIPs.
|
||
- If any existing kind or NIP might offer the required functionality, read the relevant NIPs to investigate thoroughly. Several NIPs may need to be read before making a decision.
|
||
- Only generate new kind numbers if no existing suitable kinds are found after comprehensive research.
|
||
|
||
Knowing when to create a new kind versus reusing an existing kind requires careful judgement. Introducing new kinds means the project won't be interoperable with existing clients. But deviating too far from the schema of a particular kind can cause different interoperability issues.
|
||
|
||
#### Choosing Between Existing NIPs and Custom Kinds
|
||
|
||
When implementing features that could use existing NIPs, follow this decision framework:
|
||
|
||
1. **Thorough NIP Review**: Before considering a new kind, always perform a comprehensive review of existing NIPs and their associated kinds. Get an overview of all NIPs, and then read specific NIPs and kind documentation to investigate any potentially relevant NIPs or kinds in detail. The goal is to find the closest existing solution.
|
||
|
||
2. **Prioritize Existing NIPs**: Always prefer extending or using existing NIPs over creating custom kinds, even if they require minor compromises in functionality.
|
||
|
||
3. **Interoperability vs. Perfect Fit**: Consider the trade-off between:
|
||
- **Interoperability**: Using existing kinds means compatibility with other Nostr clients
|
||
- **Perfect Schema**: Custom kinds allow perfect data modeling but create ecosystem fragmentation
|
||
|
||
4. **Extension Strategy**: When existing NIPs are close but not perfect:
|
||
- Use the existing kind as the base
|
||
- Add domain-specific tags for additional metadata
|
||
- Document the extensions in `NIP.md`
|
||
|
||
5. **When to Generate Custom Kinds**:
|
||
- No existing NIP covers the core functionality
|
||
- The data structure is fundamentally different from existing patterns
|
||
- The use case requires different storage characteristics (regular vs replaceable vs addressable)
|
||
- If you have a tool available to generate a kind, you **MUST** call the tool to generate a new kind rather than picking an arbitrary number
|
||
|
||
6. **Custom Kind Publishing**: When publishing events with custom generated kinds, always include a NIP-31 "alt" tag with a human-readable description of the event's purpose.
|
||
|
||
**Example Decision Process**:
|
||
```
|
||
Need: Equipment marketplace for farmers
|
||
Options:
|
||
1. NIP-15 (Marketplace) - Too structured for peer-to-peer sales
|
||
2. NIP-99 (Classified Listings) - Good fit, can extend with farming tags
|
||
3. Custom kind - Perfect fit but no interoperability
|
||
|
||
Decision: Use NIP-99 + farming-specific tags for best balance
|
||
```
|
||
|
||
#### Tag Design Principles
|
||
|
||
When designing tags for Nostr events, follow these principles:
|
||
|
||
1. **Kind vs Tags Separation**:
|
||
- **Kind** = Schema/structure (how the data is organized)
|
||
- **Tags** = Semantics/categories (what the data represents)
|
||
- Don't create different kinds for the same data structure
|
||
|
||
2. **Use Single-Letter Tags for Categories**:
|
||
- **Relays only index single-letter tags** for efficient querying
|
||
- Use `t` tags for categorization, not custom multi-letter tags
|
||
- Multiple `t` tags allow items to belong to multiple categories
|
||
|
||
3. **Relay-Level Filtering**:
|
||
- Design tags to enable efficient relay-level filtering with `#t: ["category"]`
|
||
- Avoid client-side filtering when relay-level filtering is possible
|
||
- Consider query patterns when designing tag structure
|
||
|
||
4. **Tag Examples**:
|
||
```json
|
||
// ❌ Wrong: Multi-letter tag, not queryable at relay level
|
||
["product_type", "electronics"]
|
||
|
||
// ✅ Correct: Single-letter tag, relay-indexed and queryable
|
||
["t", "electronics"]
|
||
["t", "smartphone"]
|
||
["t", "android"]
|
||
```
|
||
|
||
5. **Querying Best Practices**:
|
||
```typescript
|
||
// ❌ Inefficient: Get all events, filter in JavaScript
|
||
const events = await nostr.query([{ kinds: [30402] }]);
|
||
const filtered = events.filter(e => hasTag(e, 'product_type', 'electronics'));
|
||
|
||
// ✅ Efficient: Filter at relay level
|
||
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
|
||
```
|
||
|
||
#### `t` Tag Filtering for Community-Specific Content
|
||
|
||
For applications focused on a specific community or niche, you can use `t` tags to filter events for the target audience.
|
||
|
||
**When to Use:**
|
||
- ✅ Community apps: "farmers" → `t: "farming"`, "Poland" → `t: "poland"`
|
||
- ❌ Generic platforms: Twitter clones, general Nostr clients
|
||
|
||
**Implementation:**
|
||
```typescript
|
||
// Publishing with community tag
|
||
createEvent({
|
||
kind: 1,
|
||
content: data.content,
|
||
tags: [['t', 'farming']]
|
||
});
|
||
|
||
// Querying community content
|
||
const events = await nostr.query([{
|
||
kinds: [1],
|
||
'#t': ['farming'],
|
||
limit: 20
|
||
}], { signal });
|
||
```
|
||
|
||
### Kind Ranges
|
||
|
||
An event's kind number determines the event's behavior and storage characteristics:
|
||
|
||
- **Regular Events** (1000 ≤ kind < 10000): Expected to be stored by relays permanently. Used for persistent content like notes, articles, etc.
|
||
- **Replaceable Events** (10000 ≤ kind < 20000): Only the latest event per pubkey+kind combination is stored. Used for profile metadata, contact lists, etc.
|
||
- **Addressable Events** (30000 ≤ kind < 40000): Identified by pubkey+kind+d-tag combination, only latest per combination is stored. Used for articles, long-form content, etc.
|
||
|
||
Kinds below 1000 are considered "legacy" kinds, and may have different storage characteristics based on their kind definition. For example, kind 1 is regular, while kind 3 is replaceable.
|
||
|
||
### Content Field Design Principles
|
||
|
||
When designing new event kinds, the `content` field should be used for semantically important data that doesn't need to be queried by relays. **Structured JSON data generally shouldn't go in the content field** (kind 0 being an early exception).
|
||
|
||
#### Guidelines
|
||
|
||
- **Use content for**: Large text, freeform human-readable content, or existing industry-standard JSON formats (Tiled maps, FHIR, GeoJSON)
|
||
- **Use tags for**: Queryable metadata, structured data, anything that needs relay-level filtering
|
||
- **Empty content is valid**: Many events need only tags with `content: ""`
|
||
- **Relays only index tags**: If you need to filter by a field, it must be a tag
|
||
|
||
#### Example
|
||
|
||
**✅ Good - queryable data in tags:**
|
||
```json
|
||
{
|
||
"kind": 30402,
|
||
"content": "",
|
||
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]]
|
||
}
|
||
```
|
||
|
||
**❌ Bad - structured data in content:**
|
||
```json
|
||
{
|
||
"kind": 30402,
|
||
"content": "{\"title\":\"Camera\",\"price\":250,\"category\":\"photo\"}",
|
||
"tags": [["d", "product-123"]]
|
||
}
|
||
```
|
||
|
||
### NIP.md
|
||
|
||
The file `NIP.md` is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it.
|
||
|
||
Whenever new kinds are generated, the `NIP.md` file in the project must be created or updated to document the custom event schema. Whenever the schema of one of these custom events changes, `NIP.md` must also be updated accordingly.
|
||
|
||
### The `useNostr` Hook
|
||
|
||
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useCustomHook() {
|
||
const { nostr } = useNostr();
|
||
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### Connecting to Multiple Nostr Relays
|
||
|
||
By default, the `nostr` object from `useNostr` uses a pool configuration that reads data from 1 relay and publishes to all configured relays. However, you can connect to specific relays or groups of relays for more granular control:
|
||
|
||
#### Single Relay Connection
|
||
|
||
To read and publish from one specific relay, use `nostr.relay()` with a WebSocket URL:
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useSpecificRelay() {
|
||
const { nostr } = useNostr();
|
||
|
||
// Connect to a specific relay
|
||
const relay = nostr.relay('wss://relay.damus.io');
|
||
|
||
// Query from this specific relay only
|
||
const events = await relay.query([{ kinds: [1], limit: 20 }], { signal });
|
||
|
||
// Publish to this specific relay only
|
||
await relay.event({ kind: 1, content: 'Hello from specific relay!' });
|
||
}
|
||
```
|
||
|
||
#### Multiple Relay Group
|
||
|
||
To read and publish from a specific set of relays, use `nostr.group()` with an array of relay URLs:
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useRelayGroup() {
|
||
const { nostr } = useNostr();
|
||
|
||
// Create a group of specific relays
|
||
const relayGroup = nostr.group([
|
||
'wss://relay.damus.io',
|
||
'wss://relay.nostr.band',
|
||
'wss://nos.lol'
|
||
]);
|
||
|
||
// Query from all relays in the group
|
||
const events = await relayGroup.query([{ kinds: [1], limit: 20 }], { signal });
|
||
|
||
// Publish to all relays in the group
|
||
await relayGroup.event({ kind: 1, content: 'Hello from relay group!' });
|
||
}
|
||
```
|
||
|
||
#### API Consistency
|
||
|
||
Both `relay` and `group` objects have the same API as the main `nostr` object, including:
|
||
|
||
- `.query()` - Query events with filters
|
||
- `.req()` - Create subscriptions
|
||
- `.event()` - Publish events
|
||
- All other Nostr protocol methods
|
||
|
||
#### Use Cases
|
||
|
||
**Single Relay (`nostr.relay()`):**
|
||
- Testing specific relay behavior
|
||
- Querying relay-specific content
|
||
- Debugging connectivity issues
|
||
- Working with specialized relays
|
||
|
||
**Relay Group (`nostr.group()`):**
|
||
- Querying from trusted relay sets
|
||
- Publishing to specific communities
|
||
- Load balancing across relay subsets
|
||
- Geographic relay optimization
|
||
|
||
**Default Pool (`nostr`):**
|
||
- General application queries
|
||
- Maximum reach for publishing
|
||
- Default user experience
|
||
- Simplified relay management
|
||
|
||
### Query Nostr Data with `useNostr` and Tanstack Query
|
||
|
||
When querying Nostr, the best practice is to create custom hooks that combine `useNostr` and `useQuery` to get the required data.
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
import { useQuery } from '@tanstack/query';
|
||
|
||
function usePosts() {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery({
|
||
queryKey: ['posts'],
|
||
queryFn: async (c) => {
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
||
const events = await nostr.query([{ kinds: [1], limit: 20 }], { signal });
|
||
return events; // these events could be transformed into another format
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
### Efficient Query Design
|
||
|
||
**Critical**: Always minimize the number of separate queries to avoid rate limiting and improve performance. Combine related queries whenever possible.
|
||
|
||
**✅ Efficient - Single query with multiple kinds:**
|
||
```typescript
|
||
// Query multiple event types in one request
|
||
const events = await nostr.query([
|
||
{
|
||
kinds: [1, 6, 16], // All repost kinds in one query
|
||
'#e': [eventId],
|
||
limit: 150,
|
||
}
|
||
], { signal });
|
||
|
||
// Separate by type in JavaScript
|
||
const notes = events.filter((e) => e.kind === 1);
|
||
const reposts = events.filter((e) => e.kind === 6);
|
||
const genericReposts = events.filter((e) => e.kind === 16);
|
||
```
|
||
|
||
**❌ Inefficient - Multiple separate queries:**
|
||
```typescript
|
||
// This creates unnecessary load and can trigger rate limiting
|
||
const [notes, reposts, genericReposts] = await Promise.all([
|
||
nostr.query([{ kinds: [1], '#e': [eventId] }], { signal }),
|
||
nostr.query([{ kinds: [6], '#e': [eventId] }], { signal }),
|
||
nostr.query([{ kinds: [16], '#e': [eventId] }], { signal }),
|
||
]);
|
||
```
|
||
|
||
**Query Optimization Guidelines:**
|
||
1. **Combine kinds**: Use `kinds: [1, 6, 16]` instead of separate queries
|
||
2. **Use multiple filters**: When you need different tag filters, use multiple filter objects in a single query
|
||
3. **Adjust limits**: When combining queries, increase the limit appropriately
|
||
4. **Filter in JavaScript**: Separate event types after receiving results rather than making multiple requests
|
||
5. **Consider relay capacity**: Each query consumes relay resources and may count against rate limits
|
||
|
||
The data may be transformed into a more appropriate format if needed, and multiple calls to `nostr.query()` may be made in a single queryFn.
|
||
|
||
### Event Validation
|
||
|
||
When querying events, if the event kind being returned has required tags or required JSON fields in the content, the events should be filtered through a validator function. This is not generally needed for kinds such as 1, where all tags are optional and the content is freeform text, but is especially useful for custom kinds as well as kinds with strict requirements.
|
||
|
||
```typescript
|
||
// Example validator function for NIP-52 calendar events
|
||
function validateCalendarEvent(event: NostrEvent): boolean {
|
||
// Check if it's a calendar event kind
|
||
if (![31922, 31923].includes(event.kind)) return false;
|
||
|
||
// Check for required tags according to NIP-52
|
||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||
const start = event.tags.find(([name]) => name === 'start')?.[1];
|
||
|
||
// All calendar events require 'd', 'title', and 'start' tags
|
||
if (!d || !title || !start) return false;
|
||
|
||
// Additional validation for date-based events (kind 31922)
|
||
if (event.kind === 31922) {
|
||
// start tag should be in YYYY-MM-DD format for date-based events
|
||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRegex.test(start)) return false;
|
||
}
|
||
|
||
// Additional validation for time-based events (kind 31923)
|
||
if (event.kind === 31923) {
|
||
// start tag should be a unix timestamp for time-based events
|
||
const timestamp = parseInt(start);
|
||
if (isNaN(timestamp) || timestamp <= 0) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function useCalendarEvents() {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery({
|
||
queryKey: ['calendar-events'],
|
||
queryFn: async (c) => {
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
||
const events = await nostr.query([{ kinds: [31922, 31923], limit: 20 }], { signal });
|
||
|
||
// Filter events through validator to ensure they meet NIP-52 requirements
|
||
return events.filter(validateCalendarEvent);
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
### The `useAuthor` Hook
|
||
|
||
To display profile data for a user by their Nostr pubkey (such as an event author), use the `useAuthor` hook.
|
||
|
||
```tsx
|
||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
|
||
function Post({ event }: { event: NostrEvent }) {
|
||
const author = useAuthor(event.pubkey);
|
||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||
|
||
const displayName = metadata?.name ?? genUserName(event.pubkey);
|
||
const profileImage = metadata?.picture;
|
||
|
||
// ...render elements with this data
|
||
}
|
||
```
|
||
|
||
### `NostrMetadata` type
|
||
|
||
```ts
|
||
/** Kind 0 metadata. */
|
||
interface NostrMetadata {
|
||
/** A short description of the user. */
|
||
about?: string;
|
||
/** A URL to a wide (~1024x768) picture to be optionally displayed in the background of a profile screen. */
|
||
banner?: string;
|
||
/** A boolean to clarify that the content is entirely or partially the result of automation, such as with chatbots or newsfeeds. */
|
||
bot?: boolean;
|
||
/** An alternative, bigger name with richer characters than `name`. `name` should always be set regardless of the presence of `display_name` in the metadata. */
|
||
display_name?: string;
|
||
/** A bech32 lightning address according to NIP-57 and LNURL specifications. */
|
||
lud06?: string;
|
||
/** An email-like lightning address according to NIP-57 and LNURL specifications. */
|
||
lud16?: string;
|
||
/** A short name to be displayed for the user. */
|
||
name?: string;
|
||
/** An email-like Nostr address according to NIP-05. */
|
||
nip05?: string;
|
||
/** A URL to the user's avatar. */
|
||
picture?: string;
|
||
/** A web URL related in any way to the event author. */
|
||
website?: string;
|
||
}
|
||
```
|
||
|
||
### The `useNostrPublish` Hook
|
||
|
||
To publish events, use the `useNostrPublish` hook in this project. This hook automatically adds a "client" tag to published events.
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
|
||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
|
||
export function MyComponent() {
|
||
const [ data, setData] = useState<Record<string, string>>({});
|
||
|
||
const { user } = useCurrentUser();
|
||
const { mutate: createEvent } = useNostrPublish();
|
||
|
||
const handleSubmit = () => {
|
||
createEvent({ kind: 1, content: data.content });
|
||
};
|
||
|
||
if (!user) {
|
||
return <span>You must be logged in to use this form.</span>;
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} disabled={!user}>
|
||
{/* ...some input fields */}
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
|
||
|
||
### Nostr Login
|
||
|
||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||
|
||
```tsx
|
||
import { LoginArea } from "@/components/auth/LoginArea";
|
||
|
||
function MyComponent() {
|
||
return (
|
||
<div>
|
||
{/* other components ... */}
|
||
|
||
<LoginArea className="max-w-60" />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic.
|
||
|
||
`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width.
|
||
|
||
**Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states.
|
||
|
||
### `npub`, `naddr`, and other Nostr addresses
|
||
|
||
Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes:
|
||
|
||
- `npub1`: **public keys** - Just the 32-byte public key, no additional metadata
|
||
- `nsec1`: **private keys** - Secret keys (should never be displayed publicly)
|
||
- `note1`: **event IDs** - Just the 32-byte event ID (hex), no additional metadata
|
||
- `nevent1`: **event pointers** - Event ID plus optional relay hints and author pubkey
|
||
- `nprofile1`: **profile pointers** - Public key plus optional relay hints and petname
|
||
- `naddr1`: **addressable event coordinates** - For parameterized replaceable events (kind 30000-39999)
|
||
- `nrelay1`: **relay references** - Relay URLs (deprecated)
|
||
|
||
#### Key Differences Between Similar Identifiers
|
||
|
||
**`note1` vs `nevent1`:**
|
||
- `note1`: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10
|
||
- `nevent1`: Contains event ID plus optional relay hints and author pubkey - for any event kind
|
||
- Use `note1` for simple references to text notes and threads
|
||
- Use `nevent1` when you need to include relay hints or author context for any event type
|
||
|
||
**`npub1` vs `nprofile1`:**
|
||
- `npub1`: Contains only the public key (32 bytes)
|
||
- `nprofile1`: Contains public key plus optional relay hints and petname
|
||
- Use `npub1` for simple user references
|
||
- Use `nprofile1` when you need to include relay hints or display name context
|
||
|
||
#### NIP-19 Routing Implementation
|
||
|
||
**Critical**: NIP-19 identifiers should be handled at the **root level** of URLs (e.g., `/note1...`, `/npub1...`, `/naddr1...`), NOT nested under paths like `/note/note1...` or `/profile/npub1...`.
|
||
|
||
This project includes a boilerplate `NIP19Page` component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality.
|
||
|
||
**How it works:**
|
||
|
||
1. **Root-Level Route**: The route `/:nip19` in `AppRouter.tsx` catches all NIP-19 identifiers
|
||
2. **Automatic Decoding**: The `NIP19Page` component automatically decodes the identifier using `nip19.decode()`
|
||
3. **Type-Specific Sections**: Different sections are rendered based on the identifier type:
|
||
- `npub1`/`nprofile1`: Profile section with placeholder for profile view
|
||
- `note1`: Note section with placeholder for kind:1 text note view
|
||
- `nevent1`: Event section with placeholder for any event type view
|
||
- `naddr1`: Addressable event section with placeholder for articles, marketplace items, etc.
|
||
4. **Error Handling**: Invalid, vacant, or unsupported identifiers show 404 NotFound page
|
||
5. **Ready for Population**: Each section includes comments indicating where AI agents should implement specific functionality
|
||
|
||
**Example URLs that work automatically:**
|
||
- `/npub1abc123...` - User profile (needs implementation)
|
||
- `/note1def456...` - Kind:1 text note (needs implementation)
|
||
- `/nevent1ghi789...` - Any event with relay hints (needs implementation)
|
||
- `/naddr1jkl012...` - Addressable event (needs implementation)
|
||
|
||
**Features included:**
|
||
- Basic NIP-19 identifier decoding and routing
|
||
- Type-specific sections for different identifier types
|
||
- Error handling for invalid identifiers
|
||
- Responsive container structure
|
||
- Comments indicating where to implement specific views
|
||
|
||
**Error handling:**
|
||
- Invalid NIP-19 format → 404 NotFound
|
||
- Unsupported identifier types (like `nsec1`) → 404 NotFound
|
||
- Empty or missing identifiers → 404 NotFound
|
||
|
||
To implement NIP-19 routing in your Nostr application:
|
||
|
||
1. **The NIP19Page boilerplate is already created** - populate sections with specific functionality
|
||
2. **The route is already configured** in `AppRouter.tsx`
|
||
3. **Error handling is built-in** - all edge cases show appropriate 404 responses
|
||
4. **Add specific components** for profile views, event displays, etc. as needed
|
||
|
||
#### Event Type Distinctions
|
||
|
||
**`note1` identifiers** are specifically for **kind:1 events** (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr.
|
||
|
||
**`nevent1` identifiers** can reference any event kind and include additional metadata like relay hints and author pubkey. Use `nevent1` when:
|
||
- The event is not a kind:1 text note
|
||
- You need to include relay hints for better discoverability
|
||
- You want to include author context
|
||
|
||
#### Use in Filters
|
||
|
||
The base Nostr protocol uses hex string identifiers when filtering by event IDs and pubkeys. Nostr filters only accept hex strings.
|
||
|
||
```ts
|
||
// ❌ Wrong: naddr is not decoded
|
||
const events = await nostr.query(
|
||
[{ ids: [naddr] }],
|
||
{ signal }
|
||
);
|
||
```
|
||
|
||
Corrected example:
|
||
|
||
```ts
|
||
// Import nip19 from nostr-tools
|
||
import { nip19 } from 'nostr-tools';
|
||
|
||
// Decode a NIP-19 identifier
|
||
const decoded = nip19.decode(value);
|
||
|
||
// Optional: guard certain types (depending on the use-case)
|
||
if (decoded.type !== 'naddr') {
|
||
throw new Error('Unsupported Nostr identifier');
|
||
}
|
||
|
||
// Get the addr object
|
||
const naddr = decoded.data;
|
||
|
||
// ✅ Correct: naddr is expanded into the correct filter
|
||
const events = await nostr.query(
|
||
[{
|
||
kinds: [naddr.kind],
|
||
authors: [naddr.pubkey],
|
||
'#d': [naddr.identifier],
|
||
}],
|
||
{ signal }
|
||
);
|
||
```
|
||
|
||
#### Implementation Guidelines
|
||
|
||
1. **Always decode NIP-19 identifiers** before using them in queries
|
||
2. **Use the appropriate identifier type** based on your needs:
|
||
- Use `note1` for kind:1 text notes specifically
|
||
- Use `nevent1` when including relay hints or for non-kind:1 events
|
||
- Use `naddr1` for addressable events (always includes author pubkey for security)
|
||
3. **Handle different identifier types** appropriately:
|
||
- `npub1`/`nprofile1`: Display user profiles
|
||
- `note1`: Display kind:1 text notes specifically
|
||
- `nevent1`: Display any event with optional relay context
|
||
- `naddr1`: Display addressable events (articles, marketplace items, etc.)
|
||
4. **Security considerations**: Always use `naddr1` for addressable events instead of just the `d` tag value, as `naddr1` contains the author pubkey needed to create secure filters
|
||
5. **Error handling**: Gracefully handle invalid or unsupported NIP-19 identifiers with 404 responses
|
||
|
||
### Nostr Edit Profile
|
||
|
||
To include an Edit Profile form, place the `EditProfileForm` component in the project:
|
||
|
||
```tsx
|
||
import { EditProfileForm } from "@/components/EditProfileForm";
|
||
|
||
function EditProfilePage() {
|
||
return (
|
||
<div>
|
||
{/* you may want to wrap this in a layout or include other components depending on the project ... */}
|
||
|
||
<EditProfileForm />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `EditProfileForm` component displays just the form. It requires no props, and will "just work" automatically.
|
||
|
||
### Uploading Files on Nostr
|
||
|
||
Use the `useUploadFile` hook to upload files. This hook uses Blossom servers for file storage and returns NIP-94 compatible tags.
|
||
|
||
```tsx
|
||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||
|
||
function MyComponent() {
|
||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||
|
||
const handleUpload = async (file: File) => {
|
||
try {
|
||
// Provides an array of NIP-94 compatible tags
|
||
// The first tag in the array contains the URL
|
||
const [[_, url]] = await uploadFile(file);
|
||
// ...use the url
|
||
} catch (error) {
|
||
// ...handle errors
|
||
}
|
||
};
|
||
|
||
// ...rest of component
|
||
}
|
||
```
|
||
|
||
To attach files to kind 1 events, each file's URL should be appended to the event's `content`, and an `imeta` tag should be added for each file. For kind 0 events, the URL by itself can be used in relevant fields of the JSON content.
|
||
|
||
### Nostr Encryption and Decryption
|
||
|
||
The logged-in user has a `signer` object (matching the NIP-07 signer interface) that can be used for encryption and decryption. The signer's nip44 methods handle all cryptographic operations internally, including key derivation and conversation key management, so you never need direct access to private keys. Always use the signer interface for encryption rather than requesting private keys from users, as this maintains security and follows best practices.
|
||
|
||
```ts
|
||
// Get the current user
|
||
const { user } = useCurrentUser();
|
||
|
||
// Optional guard to check that nip44 is available
|
||
if (!user.signer.nip44) {
|
||
throw new Error("Please upgrade your signer extension to a version that supports NIP-44 encryption");
|
||
}
|
||
|
||
// Encrypt message to self
|
||
const encrypted = await user.signer.nip44.encrypt(user.pubkey, "hello world");
|
||
// Decrypt message to self
|
||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world"
|
||
```
|
||
|
||
### Rendering Rich Text Content
|
||
|
||
Nostr text notes (kind 1, 11, and 1111) have a plaintext `content` field that may contain URLs, hashtags, and Nostr URIs. These events should render their content using the `NoteContent` component:
|
||
|
||
```tsx
|
||
import { NoteContent } from "@/components/NoteContent";
|
||
|
||
export function Post(/* ...props */) {
|
||
// ...
|
||
|
||
return (
|
||
<CardContent className="pb-2">
|
||
<div className="whitespace-pre-wrap break-words">
|
||
<NoteContent event={post} className="text-sm" />
|
||
</div>
|
||
</CardContent>
|
||
);
|
||
}
|
||
```
|
||
|
||
## App Configuration
|
||
|
||
The project includes an `AppProvider` that manages global application state including theme and NIP-65 relay configuration. The default configuration includes:
|
||
|
||
```typescript
|
||
const defaultConfig: AppConfig = {
|
||
theme: "light",
|
||
relayMetadata: {
|
||
relays: [
|
||
{ url: 'wss://relay.ditto.pub', read: true, write: true },
|
||
{ url: 'wss://relay.nostr.band', read: true, write: true },
|
||
{ url: 'wss://relay.damus.io', read: true, write: true },
|
||
],
|
||
updatedAt: 0,
|
||
},
|
||
};
|
||
```
|
||
|
||
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
|
||
|
||
### Relay Management
|
||
|
||
The project includes a complete NIP-65 relay management system:
|
||
|
||
- **RelayListManager**: Component for managing multiple relays with read/write permissions
|
||
- **NostrSync**: Automatically syncs user's NIP-65 relay list when they log in
|
||
- **Automatic Publishing**: Changes to relay configuration are automatically published as NIP-65 events when the user is logged in
|
||
|
||
Use the `RelayListManager` component to provide relay management interfaces:
|
||
|
||
```tsx
|
||
import { RelayListManager } from '@/components/RelayListManager';
|
||
|
||
function SettingsPage() {
|
||
return (
|
||
<div>
|
||
<h2>Relay Settings</h2>
|
||
<RelayListManager />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Routing
|
||
|
||
The project uses `react-router-dom`'s **HashRouter** with a centralized
|
||
configuration in `AppRouter.tsx`. Because it's a hash router, the route is the
|
||
part after `#` — `https://skate-spots.org/#/settings`. Links must use `<Link>`
|
||
(a bare `<a href="/settings">` or a plain path loads the homepage / 404s). To add
|
||
a route:
|
||
|
||
1. Create your page component in `/src/pages/`
|
||
2. Add it with **`lazyWithRetry`** (not bare `lazy`) and a `<Route>` above the
|
||
catch-all `*` route:
|
||
|
||
```tsx
|
||
const YourPage = lazyWithRetry(() => import("./pages/YourPage"));
|
||
// …
|
||
<Route path="/your-path" element={<YourPage />} />
|
||
```
|
||
|
||
The router includes automatic scroll-to-top functionality and a 404 NotFound
|
||
page for unmatched routes.
|
||
|
||
## Development Practices
|
||
|
||
- Uses React Query for data fetching and caching
|
||
- Follows shadcn/ui component patterns
|
||
- Implements Path Aliases with `@/` prefix for cleaner imports
|
||
- Uses Vite for fast development and production builds
|
||
- Component-based architecture with React hooks
|
||
- Default connection to one Nostr relay for best performance
|
||
- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider
|
||
- **Never use the `any` type**: Always use proper TypeScript types for type safety
|
||
|
||
## Loading States
|
||
|
||
**Use skeleton loading** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations.
|
||
|
||
```tsx
|
||
// Skeleton example matching component structure
|
||
<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>
|
||
```
|
||
|
||
### Empty States and No Content Found
|
||
|
||
When no content is found (empty search results, no data available, etc.), display a minimalist empty state with helpful messaging. The application uses NIP-65 relay management, so users can manage their relays through the settings or relay management interface.
|
||
|
||
```tsx
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
|
||
// Empty state example
|
||
<div className="col-span-full">
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-12 px-8 text-center">
|
||
<div className="max-w-sm mx-auto space-y-6">
|
||
<p className="text-muted-foreground">
|
||
No results found. Try checking your relay connections or wait a moment for content to load.
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
```
|
||
|
||
## CRITICAL Design Standards
|
||
|
||
- Create breathtaking, immersive designs that feel like bespoke masterpieces, rivaling the polish of Apple, Stripe, or luxury brands
|
||
- Designs must be production-ready, fully featured, with no placeholders unless explicitly requested, ensuring every element serves a functional and aesthetic purpose
|
||
- Avoid generic or templated aesthetics at all costs; every design must have a unique, brand-specific visual signature that feels custom-crafted
|
||
- Headers must be dynamic, immersive, and storytelling-driven, using layered visuals, motion, and symbolic elements to reflect the brand’s identity—never use simple “icon and text” combos
|
||
- Incorporate purposeful, lightweight animations for scroll reveals, micro-interactions (e.g., hover, click, transitions), and section transitions to create a sense of delight and fluidity
|
||
|
||
### Design Principles
|
||
|
||
- Achieve Apple-level refinement with meticulous attention to detail, ensuring designs evoke strong emotions (e.g., wonder, inspiration, energy) through color, motion, and composition
|
||
- Deliver fully functional interactive components with intuitive feedback states, ensuring every element has a clear purpose and enhances user engagement
|
||
- Use custom illustrations, 3D elements, or symbolic visuals instead of generic stock imagery to create a unique brand narrative; stock imagery, when required, must be sourced exclusively from Pexels (NEVER Unsplash) and align with the design’s emotional tone
|
||
- Ensure designs feel alive and modern with dynamic elements like gradients, glows, or parallax effects, avoiding static or flat aesthetics
|
||
- Before finalizing, ask: "Would this design make Apple or Stripe designers pause and take notice?" If not, iterate until it does
|
||
|
||
### Avoid Generic Design
|
||
|
||
- No basic layouts (e.g., text-on-left, image-on-right) without significant custom polish, such as dynamic backgrounds, layered visuals, or interactive elements
|
||
- No simplistic headers; they must be immersive, animated, and reflective of the brand’s core identity and mission
|
||
- No designs that could be mistaken for free templates or overused patterns; every element must feel intentional and tailored
|
||
|
||
### Interaction Patterns
|
||
|
||
- Use progressive disclosure for complex forms or content to guide users intuitively and reduce cognitive load
|
||
- Incorporate contextual menus, smart tooltips, and visual cues to enhance navigation and usability
|
||
- Implement drag-and-drop, hover effects, and transitions with clear, dynamic visual feedback to elevate the user experience
|
||
- Support power users with keyboard shortcuts, ARIA labels, and focus states for accessibility and efficiency
|
||
- Add subtle parallax effects or scroll-triggered animations to create depth and engagement without overwhelming the user
|
||
|
||
### Technical Requirements
|
||
|
||
- Curated color FRpalette (3-5 evocative colors + neutrals) that aligns with the brand’s emotional tone and creates a memorable impact
|
||
- Ensure a minimum 4.5:1 contrast ratio for all text and interactive elements to meet accessibility standards
|
||
- Use expressive, readable fonts (18px+ for body text, 40px+ for headlines) with a clear hierarchy; pair a modern sans-serif (e.g., Inter) with an elegant serif (e.g., Playfair Display) for personality
|
||
- Design for full responsiveness, ensuring flawless performance and aesthetics across all screen sizes (mobile, tablet, desktop)
|
||
- Adhere to WCAG 2.1 AA guidelines, including keyboard navigation, screen reader support, and reduced motion options
|
||
- Follow an 8px grid system for consistent spacing, padding, and alignment to ensure visual harmony
|
||
- Add depth with subtle shadows, gradients, glows, and rounded corners (e.g., 16px radius) to create a polished, modern aesthetic
|
||
- Optimize animations and interactions to be lightweight and performant, ensuring smooth experiences across devices
|
||
|
||
### Components
|
||
|
||
- Design reusable, modular components with consistent styling, behavior, and feedback states (e.g., hover, active, focus, error)
|
||
- Include purposeful animations (e.g., scale-up on hover, fade-in on scroll) to guide attention and enhance interactivity without distraction
|
||
- Ensure full accessibility support with keyboard navigation, ARIA labels, and visible focus states (e.g., a glowing outline in an accent color)
|
||
- Use custom icons or illustrations for components to reinforce the brand’s visual identity
|
||
|
||
### Adding Fonts
|
||
|
||
To add custom fonts, follow these steps:
|
||
|
||
1. **Install a font package** using npm:
|
||
|
||
**Any Google Font can be installed** using the @fontsource packages. Examples:
|
||
- For Inter Variable: `@fontsource-variable/inter`
|
||
- For Roboto: `@fontsource/roboto`
|
||
- For Outfit Variable: `@fontsource-variable/outfit`
|
||
- For Poppins: `@fontsource/poppins`
|
||
- For Open Sans: `@fontsource/open-sans`
|
||
|
||
**Format**: `@fontsource/[font-name]` or `@fontsource-variable/[font-name]` (for variable fonts)
|
||
|
||
2. **Import the font** in `src/main.tsx`:
|
||
```typescript
|
||
import '@fontsource-variable/<font-name>';
|
||
```
|
||
|
||
3. **Update Tailwind configuration** in `tailwind.config.ts`:
|
||
```typescript
|
||
export default {
|
||
theme: {
|
||
extend: {
|
||
fontFamily: {
|
||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||
},
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
### Recommended Font Choices by Use Case
|
||
|
||
- **Modern/Clean**: Inter Variable, Outfit Variable, or Manrope
|
||
- **Professional/Corporate**: Roboto, Open Sans, or Source Sans Pro
|
||
- **Creative/Artistic**: Poppins, Nunito, or Comfortaa
|
||
- **Technical/Code**: JetBrains Mono, Fira Code, or Source Code Pro (for monospace)
|
||
|
||
### Theme System
|
||
|
||
The project includes a complete light/dark theme system using CSS custom properties. The theme can be controlled via:
|
||
|
||
- `useTheme` hook for programmatic theme switching
|
||
- CSS custom properties defined in `src/index.css`
|
||
- Automatic dark mode support with `.dark` class
|
||
|
||
### Color Scheme Implementation
|
||
|
||
When users specify color schemes:
|
||
- Update CSS custom properties in `src/index.css` (both `:root` and `.dark` selectors)
|
||
- Use Tailwind's color palette or define custom colors
|
||
- Ensure proper contrast ratios for accessibility
|
||
- Apply colors consistently across components (buttons, links, accents)
|
||
- Test both light and dark mode variants
|
||
|
||
### Component Styling Patterns
|
||
|
||
- Use `cn()` utility for conditional class merging
|
||
- Follow shadcn/ui patterns for component variants
|
||
- Implement responsive design with Tailwind breakpoints
|
||
- Add hover and focus states for interactive elements
|
||
|
||
## Writing Tests vs Running Tests
|
||
|
||
There is an important distinction between **writing new tests** and **running existing tests**:
|
||
|
||
### Writing Tests (Creating New Test Files)
|
||
|
||
**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when:
|
||
|
||
1. **The user explicitly asks for tests** to be written in their message
|
||
2. **The user describes a specific bug in plain language** and requests tests to help diagnose it
|
||
3. **The user says they are still experiencing a problem** that you have already attempted to solve (tests can help verify the fix)
|
||
|
||
**Never write tests because:**
|
||
- Tool results show test failures (these are not user requests)
|
||
- You think tests would be helpful
|
||
- New features or components are created
|
||
- Existing functionality needs verification
|
||
|
||
### Running Tests (Executing the Test Suite)
|
||
|
||
**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not.
|
||
|
||
- **You must run the test script** to validate your changes
|
||
- **Your task is not complete** until the test script passes without errors
|
||
- **This applies to all changes** - bug fixes, new features, refactoring, or any code modifications
|
||
- **The test script includes** TypeScript compilation, ESLint checks, and existing test validation
|
||
|
||
### Test Setup
|
||
|
||
The project uses Vitest with jsdom environment and includes comprehensive test setup:
|
||
|
||
- **Testing Library**: React Testing Library with jest-dom matchers
|
||
- **Test Environment**: jsdom with mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver)
|
||
- **Test App**: `TestApp` component provides all necessary context providers for testing
|
||
|
||
The project includes a `TestApp` component that provides all necessary context providers for testing. Wrap components with this component to provide required context providers:
|
||
|
||
```tsx
|
||
import { describe, it, expect } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { TestApp } from '@/test/TestApp';
|
||
import { MyComponent } from './MyComponent';
|
||
|
||
describe('MyComponent', () => {
|
||
it('renders correctly', () => {
|
||
render(
|
||
<TestApp>
|
||
<MyComponent />
|
||
</TestApp>
|
||
);
|
||
|
||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
## Validating Your Changes
|
||
|
||
**CRITICAL**: After making any code changes, you must validate your work by running available validation tools.
|
||
|
||
**Your task is not considered finished until the code successfully type-checks and builds without errors.**
|
||
|
||
### Validation Priority Order
|
||
|
||
Run available tools in this priority order:
|
||
|
||
1. **Type Checking** (Required): Ensure TypeScript compilation succeeds
|
||
2. **Building/Compilation** (Required): Verify the project builds successfully
|
||
3. **Linting** (Recommended): Check code style and catch potential issues
|
||
4. **Tests** (If Available): Run existing test suite
|
||
5. **Git Commit** (Required): Create a commit with your changes when finished
|
||
|
||
**Minimum Requirements:**
|
||
- Code must type-check without errors
|
||
- Code must build/compile successfully
|
||
- Fix any critical linting errors that would break functionality
|
||
- Create a git commit when your changes are complete
|
||
|
||
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
|
||
|
||
### Using Git
|
||
|
||
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
|
||
|
||
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes. |