SkateSpots/AGENTS.md
SkateSpots ef01c3a5ed fix(map): stop deleted spots flashing back from cache
A NIP-09 kind-5 delete is only honoured by relays and was never applied
locally, so on refresh the persisted React Query cache and the Dexie spot
cache re-rendered a just-deleted spot for a few seconds until fresh data
(or the admin hidden-label query) filtered it out.

Add src/lib/localDeletions.ts: a device-local tombstone keyed by coord
and event id, written optimistically by useDeleteEntity on the owner path
(rolled back if signing/publishing fails). The shared filterAdminHidden/
filterAdminHiddenMixed now also drop tombstoned items, so every list hook
filters them from first render with no per-hook changes. useSkateSpot
returns null for a tombstoned spot, and cacheSpots/cacheSpot refuse and
evict tombstoned rows. Prune by tombstone only (callers use limits 8-2000,
so pruning by 'not in fresh result' would wipe valid cached spots).
2026-10-09 09:03:16 +07:00

232 KiB
Raw Blame History

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
      • CreateEventPage can link a shop as the venue too (not just a spot): ['a','30017:<pk>:<d>'] + ['venue-name', <name>] (2026-10-04).
    • 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).
    • Coach profile enrichment (2026-10-04): the coach (kind 35001) gains portfolio/experience fields beyond the shared base — repeated named tags (multi-letter, client-filtered like experience/rate): format (one-on-one/group/camp/video-analysis/contest-prep/park-lesson/adaptive/ girls-session/after-school), level, age, cert (self-attested first-aid/cpr/safeguarding/background-check/insured/federation/qualification/ judge + free-form cert_note — never shown as verified), language (free-form), availability, booking (URL), photo (repeatable gallery, ≤12) and video (YouTube/Instagram reel). Option sets + i18n keys live in src/lib/coachOptions.ts; the form renders a reusable OptionChips row; the gallery reuses PhotoDropzone + ImageLightbox; the reel reuses parseCrewVideo (src/lib/videoEmbed.ts). parseSkateCoach exposes formats/levels/ ageGroups/credentials/languages/availability/booking/photos/video on SkateCoach. Documented in NIP.md → "Coach (kind 35001)". /lab coaching filters (2026-10-04): the filter drawer gains a per-coaching facet section (formats/levels/ages/credentials/availability/ languages) built from COACH_FACETS — only values actually present in the loaded profiles are offered; selections show as removable chips in the toolbar and count toward activeFilterCount. Client-side only (these tags are not relay-indexed). Testimonials are already covered by the existing Lab scene-vouch system (VouchCard, kind 1985) — no new kind needed. Structured rate + form polish (2026-10-04): rate_amount/rate_currency (sats/USD/EUR/GBP) × rate_unit (hour/session/half-day/day/camp) replace the free-text-only rate; the legacy rate still renders and acts as an optional note. The form is grouped with a FormSection divider, the identity block is a neutral avatar card (it was a red error-looking box), and chips use min-h-0 — the global mobile button { min-height:44px } rule turned every chip into a 44px slab otherwise. Payments (2026-10-04): coaches declare accepted methods (payment_method: cash/lightning/cashu/bank/other) + an optional payment_note, a pay_what_you_can flag, and an optional booking_fee (+booking_fee_currency). A booking fee is paid directly to the coach's kind-0 lud16 via the existing PayWithSats (NWC→WebLN→Cashu) — mounted only while open — and is not escrowed/refundable through us (copy says so). In-app payment is offered only for sats when the coach has a Lightning address. Fiat/card and a refundable deposit are deliberately out of scope (no custody/KYC; escrow stays native-only/unverified). No tag named payment — that's the marketplace offer pointer.
    • Shared Lab enrichment (2026-10-04 — all crafts): the coach fields above were generalized so every Lab kind (35001–35014) gets the same craft-agnostic treatment without copy-paste. src/lib/labExtras.ts (parseLabExtras + option sets), components/LabExtrasFields.tsx (the form block) and components/LabExtrasSection.tsx (the detail block). Covered: languages, availability + booking link, structured rate, payment methods/note/PWYC/booking fee, portfolio gallery + reel. CraftProfileForm (the 8 data-driven crafts) and the 4 bespoke pages (filmer/editor/photographer/builder) render LabExtrasFields and append its managed tags on save; the shared CoachDetailPage renders LabExtrasSection for every craft (coach-specific formats/levels/ages/ credentials stay in the coach block). Per-craft context stays in skillSections + extraFields, and per-craft rateUnits (billing units) are set in becomeRoles.tsx. /lab filters read parseLabExtras for all crafts. ⚠️ A new craft-specific freeform extraField is invisible unless the detail page has a matching section — declare it in src/lib/craftFields.ts (CRAFT_DETAIL_FIELDS) as well as the form. A small per-craft set is in place (deliverables/turnaround, deposit/minimum, travel, lead_time, …); toggle tags store "true" and render as a localized "Yes".
    • Lab offer/publish UX (2026-10-04): the create→post loop got its missing pieces. components/LabProfilePreview.tsx is a live "how it will look" preview fed a draft from the form (title/bio/image/location/experience/rate/ skills/contact + the shared enrichment tags); wired into CraftProfileForm and all 4 bespoke pages + coach via a Preview button next to Submit. parseLabExtras now accepts any { tags } (not just a full event) so drafts preview. On a first publish the form redirects with ?published=1, and the profile shows a one-time "You're live — share it" banner. The owner "Manage" card has an Edit profile link (/become/<craft>), every profile has a Share button (ShareProfileDialog with a path override), and /lab shows a "Your Lab profiles" strip. main.tsx's clean-link shim now also rewrites /coach/<pubkey>/<d>. Still open: draft/autosave and a multi-step layout.
    • Lab offer form polish (2026-10-04, part 2): four more gaps closed. Drafts: hooks/useLabDraft autosaves the form snapshot to localStorage (content-debounced) and only offers it as resumable when it differs from the current state (so opening an existing profile never shows a spurious banner and a draft can't clobber a fresh load); LabDraftBanner gives Resume / Discard; clears on publish. Progress: LabProfileProgress is a completeness bar (photo/bio/skills/rate/location/contact) — a progress indicator, not a wizard. Validation: CraftProfileForm + the coach page split contact validation into per-field inline errors (+ red border) and scroll to the first invalid field / the skills grid on submit; required skill sections show an inline message after a submit attempt. URLs: Lab profiles now live at /lab/<craft>/<pubkey>/<id> (lib/labRoutes.ts builds it; /coach/... still resolves). The bespoke pages have inline contact errors but not the scroll-to behavior (yet).
    • Lab offer wizard (2026-10-04, part 3): the long offer forms are now multi-step. components/FormWizard.tsx (FormWizard + WizardStep) is a controlled step shell — non-active steps are hidden with display:none, not unmounted, so stateful children (LabExtrasFields) keep their values; the form owns the step so it can jump to the offending step on a validation failure. Applied to CraftProfileForm (8 crafts, 5 steps: Identity / What you offer / Rates / Contact / Media) and the coach page (5 steps); the footer renders Back/Next/Submit + a Preview button. The 4 bespoke pages keep the one-scroll + progress bar (their sections have no reliable boundaries to split) and now scroll to their contact error on a failed submit. A bespoke-page wizard is the remaining optional item.
    • 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
    • Crew handles (2026-10-03): a crew (kind 35006) may carry up to 5 nip05 tags, each verified against the founder key (SkateCrew.nip05s; nip05 = first, back-compat). CrewForm adds/removes handles with a live verified/mismatch status; CrewNip05Badge renders them all. Unlike a kind-0 profile (a single nip05 string), a crew is our own tag convention, so it isn't limited to one. Normalise → dedupe → regex-validate → cap 5 in parseSkateCrew.
    • Shop handles (2026-10-03): same idea on shops (kind 30017) — repeatable nip05 tags (up to 5) verified against the owner key (SkateShop.nip05s), added/removed in CreateShopPage/EditShopPage ('nip05' is in EditShopPage's MANAGED_TAGS so republish doesn't duplicate them); shown via Nip05Badges on the shop page.
    • Profile extra handles (2026-10-03): a profile can advertise up to 5 extra personal NIP-05 handles, since kind-0 has only one standard nip05 field and other clients may drop non-standard keys. The extras live in a separate NIP-78 record (kind 30078, d=skatespots:profile-handles, authored by the user; see lib/profileHandles.ts + hooks/useProfileHandles). Primary stays in kind-0 nip05; EditProfileForm edits extras; NIP19Page shows all via Nip05Badges. Shared editor: components/Nip05HandlesEditor.tsx (used by profile/crew/shop).
    • Shared NIP-05 UI (2026-10-03): components/Nip05Badges.tsx is the generic verify-and-render chip row (any owner pubkey); useCrewNip05 is the generic verifier (name kept for back-compat).
    • Rider Cards (2026-10-02) — now REAL + verifiable. 32271 Rider Card (addressable, d=rider-card) — the skater signs their own card customization (theme/style/skills/sponsors/rides-for/show-flags; v:1 marks show flags explicit). 35679 Park Access Card (venue-bound via an optional ["a","30818:<parkPubkey>:<d>"] tag). 1293 witnessed skill badge. Routes: /card (editor) · /#/card/<npub> (public, read-only) · /#/verify/<naddr> (signature check + venue binding). Helpers in src/lib/riderCard.ts (parseRiderCard/riderCardTags/cardVerifyUrl), hooks useRiderCard/useRiderBadges, visuals in src/components/RiderCard.tsx (ONE React renderer; the canvas export in RiderCardPage is the print one). ⚠️ A valid signature proves the SIGNER — not skill, and not access rights beyond what the venue issued; the card says so. A pass with no a tag verifies as a signature but is shown not venue-bound (any key can mint one). The QR now encodes the verify URL (was a profile path that HashRouter ignored). Identity facets (2026-10-02): the card also shows crew crests, a local badge (skater profile location/home_spot), and Lab role chips — all derived LIVE (kinds 35006 / 35000 / 35001–35014), never claimed on the card, toggled by show flags (v:2; old cards default them ON). Rendered in the React card AND the canvas export (roles as chips, local/crews as lines). Crew Card (2026-10-02): the /card editor has a third type — Crew — which renders a selected crew as a card. No new event: it is derived from the crew's own signed kind 35006, so its QR verifies that event (the founder's key is the signature). /verify/:naddr renders kind 35006 too. Card fields: name/logo/home turf/founded/members/spots/vibes + Open call when lookingFor. Event pass/wristband (2026-10-02): a fourth /card type — Event — renders an event (kind 31923) you organise or RSVP'd going/interested to (useMyRsvpCoords, kind 31925). Also derived (the organiser's key signs it), so the QR verifies that event; /verify/:naddr renders kind 31923 too. Shows title/date/venue/cover + who it admits + going count. Claims tab (2026-10-02): /review has a Claims tab (pending private-spot ownership claims; was a banner above the tabs). Card Studio (2026-10-02): /card's "My Card" tab is now a two-pane studio — sticky live preview (lg) + inspector; actions collapsed to Share + a ⋯ menu (PNG/print/copy); a dirty/saved state with a manual Save changes (no autosave — an interactive signer must not prompt per keystroke); card type = compact chips; the two toggle grids merged into one "Show on card" list with live values. Self-claimed skills removed (were marginal/ unverifiable) — the card shows witnessed badges (kind 1293) only, and style/level are DERIVED from the Skater profile (kind 35000). The canvas export + CardData dropped the self-claimed skills row and gained level. Customization depth (2026-10-02): card config gained accent (custom hex overriding the theme via --ss-accent + canvas palette), logo (personal crest, uploaded), and back (printable reverse side — React RiderCardBack
      • canvas drawCardBack; Print renders both sides). Park passes enforce valid-thru (passExpiry() → EXPIRED badge on card + verify; the input is a date picker). Card schema → v:3. Public /#/card/:npub also flips + shows the custom accent/logo. Hero redesign (2026-10-02): the card is now a full-bleed photo/cover with a bottom scrim, the name + identity overlaid bottom-left and the QR floating bottom-right — no vertical rules / columns (the old portrait panel + dashed stub read as two ugly lines). No photo → a monogram watermark. Card pages resolve the profile via useCardProfile (tolerant JSON over querySkateContent, so it can't drop a real profile the way useAuthor could); DEFAULT_RIDER_CARD.style is now '' (no fake "Street"). Issuance logic (2026-10-02): card types are gated by what you can actually issue/derive — Park only if you own a venue (a spot you authored), Crew only if you're in a crew, Event only if you organised/RSVP'd. A Park pass requires a venue binding (a tag) and is only issuable by that venue's owner key; /verify shows the holder + venue match. The studio shows a "Finish your card" checklist (photo/name/styles/crew). Event tickets (2026-10-02): an attendee's card is labelled MY TICKET, an organiser's EVENT · HOST (never a self-made "issued" pass); a ticket's QR carries ?h=<holder-npub> so /verify checks the holder's RSVP (kind 31925) and shows Ticket holder + RSVP status. Ownership model + badges (2026-10-02): owner = the event's author key (spot 30818 / shop 30017 / event 31923 / crew 35006); crew delegated admins = admin-marker p tags (useCrewRole(crew).isElevated); platform moderators = hardcoded ADMIN_PUBKEYS. ControllerBadge (components/ControllerBadge.tsx) surfaces the viewer's role — spot=Owner, shop=Verified owner (NIP-05 domain match), crew=Owner/Admin, event=Organiser — on detail pages and on the card studio's type chips (role.* i18n). NFC (2026-10-02): src/lib/nfc.ts writes the card/pass verify URL — Web NFC (NDEFReader) on Chrome/Android, or the local Android plugin packages/skatespots-nfc (SkateSpotsNfc, registered by name via @capacitor/core) in the APK, because Web NFC isn't exposed in the Capacitor WebView. NfcWriteButton renders nothing where unsupported (iOS/desktop), wired to the issuer lists + the holder's "My passes". ⚠️ UNVERIFIED (needs cap sync + a device); iOS can't write tags. A tag carries only the pointer — trust is still the relay signature. ✅ It compiles: npm run android:build wires skatespots-nfc in and merges the NFC permission (the plugin needs androidx.appcompat on its classpath — Capacitor's activity is an AppCompatActivity). Tag-writing still needs a device test (the plugin is otherwise unverified). Audit fixes (2026-10-02): removed the now-dead stats (the redesign dropped the stats row, so the show flags are only crews/local/roles and spotsAdded/zapsReceived/eventsCreated/memberYear are gone from the card types); holographic is persisted (holo tag); the public card derives style/level from the Skater profile (was config.style = ''); the share link is /#/card/<npub> (was a path HashRouter ignored); issued passes dedupe by d (a revoke no longer shows two rows); PNG download is labelled "Save front (PNG)" when a back exists. Cosmetics (2026-10-02): the preview is zoomable; the page title is translated; legacy style/skills are gone from the config/CardData; card links are clean (/verify/<naddr>, /card/<npub>) via a main.tsx startup shim that rewrites those paths to the hash (old /#/… links still work). Venue-page pass management (2026-10-02): VenueAccessCard (components/VenueAccessCard.tsx, owner-only) is rendered on the shop page and on gated spots (accessType private/locals) — it shows the venue's active/revoked pass counts and links into the studio with the venue preselected (/card?venue=<kind>:<pubkey>:<d>; RiderCardPage reads the param and opens the park editor on that venue). Renders nothing for non-owners, so no relay query fires.
  • 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) are a counter-offer negotiation thread (2026-09-29): every message is its own event with a unique d (append-only) carrying role (offer = buyer / response = seller), e = the message it answers, and status; either side can accept / decline / withdraw / counter and ListingOffersPanel shows the volley + whose turn it is. Legacy fixed-slot ds are still parsed (role inferred when the role tag is absent) — see NIP.md → "Marketplace Offers". Trade/offer steps also feed useTradeNotifications → the bell's All tab (TradeNotificationCard, counted in the badge against seenAt) and OS notifications (new trades category in notificationPrefs, wired in OsNotificationBridge). The bell also has a dedicated Trades tab (5th tab, Handshake) listing them, with its own count. Types cover every step: offer, counter, accepted, declined, withdrawn, trade-request (they asked you to confirm) and trade-confirm (they confirmed — usePendingTradeConfirmations, now carrying createdAt/eventId so it can be notified; confirming back is what makes the trade mutual and builds reputation). Barter is supported by design: trade attestations carry method (cash|lightning|trade|other — MarkSoldDialog picks it), so a swap feeds reputation exactly like a sale. Barter is first-class on the offer side too (2026-09-29): an offer/counter may carry an item tag (["item","my 8.25 deck"]) instead of or alongside amount — OfferDialog and the counter UI expose it, and the bell/OS text reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already support price_type=trade ("Looking to swap"); LISTING_TYPES itself is still selling|wtb.
    • In-app payment on an accepted offer (2026-10-01): the seller may attach a ["payment", "<value>", "<kind>"] pointer to the accepted 30403 message (kind = lninvoice|lnaddress|creq|clink; optional ["payment_hash","<hex>"]). ListingOffersPanel.respond('accepted') attaches the seller's own profile lud16 when present; the pointer lives on the accepted event so it rides the same decrypt-free notification path as the offer (a NIP-17 DM was rejected — it can't reach the Trades tab without decrypting, which OsNotificationBridge forbids). Buyer sees a Pay with sats button → PayWithSats dialog → usePayPaymentPointer pays via NWC → WebLN → Cashu melt (mirrors useZaps), falling back to the seller's lud16 if the accept carried no pointer. Nothing is held or signed by us — recording the trade stays the existing 31871 flow (proof tag documented in NIP.md). creq/clink are recognized but not payable yet. This is the Numo/BTCPay-derived "pay the seller in-app" rail; the request format is ours (offer tags), not a new kind.
    • Buy with sats — fixed price, no negotiation (2026-10-01): a price_type=fixed listing shows a direct Buy with sats button in ListingTradeActions (shared by card + detail). The price is resolved to sats — SATS directly, USD via useUsdToSats (live rate); other currencies fall back to the offer/contact flow — and paid through the same PayWithSats sheet (NWC → WebLN → Cashu melt, falling back to the seller's lud16). ⚠️ The sheet is mounted only while open so its wallet hooks don't run on every grid card.
    • Shipment / fulfilment (2026-10-01): listings carry an optional ["fulfilment","pickup"|"ships"|"both"] (selling only) — a create-page selector (default pickup), a card badge, and a Pickup/Ships filter on GearSwapPage. ⚠️ The app never collects an address: a ships deal exchanges it privately in the trade chat (NIP-17), and ListingTradeActions nudges escrow for shipped deals. No KYC rail, no carrier API, no labels.
    • Shipping flow + distance + guided handoff (2026-10-01): ["shipping","buyer"|"seller"|"split"] (who pays postage; selector on CreateListingPage when fulfilment != pickup). GearSwapItemCard / MarketplaceItemCard show distance from the viewer's last known location (cache only via getLastLocation — never triggers a GPS prompt) and GearSwapPage gains a Nearest (local) sort (itemDistanceKm in lib/sortOptions.ts). Guided handoff — HandoffHelper (rendered in ListingTradeActions, buyer-only) opens the trade DM pre-filled (/messages/<npub>?message=…): "Suggest a time & place", "Share delivery details", "Use a locker / parcel point", plus anti-scam copy (prefer escrow, never pay outside the rails, meet in public). Details stay in the NIP-17 DM — the app never sees them. ships deals now get a safety nudge on all platforms, not only when escrow is available.
    • Meet-at-a-spot + shipment status (2026-10-01): HandoffHelper also suggests the 3 nearest public skate spots to the listing (public data only — never the viewer's live location) as one-tap DM meetup templates. New addressable kind 34967 (useShipmentStatus + ShippingStatusCard): seller marks shipped, buyer received, either issue. p-tagged to the counterparty so the push service notifies (MARKET_KINDS in push-service), and d = ship:<listing-d>:<author> so a status replaces only its author's. ⚠️ It's a claim, not proof — received does NOT release escrow, no address is stored, and parcel photos showing an address are discouraged.
    • Low-diversity ring warning (2026-10-01): beyond the single-partner warning, TradeReputationCard flags verified >= 5 && verifiedCounterparties <= 2 — "most confirmations come from very few accounts".
    • Anti-sybil depth (2026-10-01): src/hooks/useTradeIntegrity.ts adds two levers to TradeReputationCard. Activity weighting — useBatchActivity counts each counterparty's published spots+listings and multiplies their contribution by min(1, activity/3), so a ring of empty accounts contributes ~0 even when cross-confirmed. Ring analysis — useTradeRings does a 2-hop query of the partners' own attestations and measures closedness (share of possible partner↔partner trades); ≥3 partners with ≥0.5 closedness flags a closed cluster.
    • NIP-13 proof of work (2026-10-01): src/lib/pow.ts (minePow / verifyPow / countLeadingZeroBits). ⚠️ PoW pays no one — it is a free CPU tax, not a fee; don't describe it as a payment. useNostrPublish accepts pow?: number and mines the nonce on the UNSIGNED event before signing (the id is signature-independent, so the signer is still prompted once). CreateListingPage sends 16 bits on every listing (~80 ms) and 20 bits when a newcomer picks work instead of the 1,000-sat listing fee (SpamFeePayment.onPowInstead → the free alternative). parseMarketplaceItem exposes powDifficulty and MarketplaceItemCard shows a ⚡ PoW badge at ≥16. It raises cost, it doesn't prove humanity; keep targets low (mining runs on the main thread with periodic yields).
    • Payment receipts — money vs goods (2026-10-01): src/lib/receipts.ts + getPaymentHashFromBolt11 (in lib/bolt11.ts, verified against the BOLT11 spec test vector). usePayPaymentPointer now captures the preimage from NWC/WebLN/melt and returns a PaymentReceipt; PayWithSats.onPaid(receipt) carries it, held device-locally (saveReceipt/getReceipt/clearReceipt) so the later CONFIRM step can attach it — publishTrade({ paymentProof }) writes ["proof","lnpreimage",preimage,payment_hash] (and depositProof → ["proof","deposit",…]). useTradeReputation verifies it (sha256(preimage) === payment_hash) and counts it in paymentVerified. ⚠️ A receipt proves the PAYMENT, not delivery — and applies to money legs only: a pure hardware swap has no invoice → no preimage → it stays attested; the deposit proof is the lever that gives barter a real cost. Never promote a barter or a plain lninvoice claim to verified. The barter deposit is now a UI affordance: ListingTradeActions shows a "Good-faith deposit" input for price_type=trade listings; paying it stores a deposit-slot receipt (saveReceipt(coord, r, 'deposit')) that the buyer's confirm attaches as depositProof (method=trade).
  • 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).
  • QR scanner: BIP21 + Cashu payment requests (2026-10-01). classifyQRCode (src/hooks/useQRCodeScanner.ts) now parses bitcoin: BIP21 — extracting lightning= (→ lightning_invoice, payable today via NWC/Cashu melt) and creq= — plus standalone NUT-18 creq1…/creqA… as a new cashu_request type. This is the Numo POS interop: a Numo terminal's default "Unified" QR is bitcoin:?creq=…&lightning=…, so it pays through the existing Lightning path with no creq implementation. cashu_request is recognized but NOT payable yet — CashuWalletCard shows an honest toast; paying NUT-18 is a later step. ⚠️ A creq is amount-bound/expiring, so a Lightning address (lud16) is the robust default for any future listing payment pointer (BIP21 parsing deliberately avoids URLSearchParams so a raw creqA… base64 + isn't coerced to a space).
  • 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 & Support hub (2026-10-04): all paid extras now have one home — Settings → Pro & Support (?section=pro, components/ProSupportScreen.tsx, menu row + section in SettingsPage). It shows the honest "prepaid, not a subscription" framing, Sasquatch Pro status (days left) + PRO_TIERS with per-day sats + a "Go Pro / Extend" button (opens SasquatchProDialog), the Featured placement types (PLATFORM_FEES × DURATION_TIERS, per-day + best-value), your currently-featured Lab listings (featuredUntil), and the free paths (PoW listing, free AI FAQ). Distinct naming is enforced in copy: Sasquatch Pro (AI) vs Featured placement (visibility) — don't call both "Pro". Not done: an actual discounted bundle SKU (would be a single combined zap, no new rails). /settings?section=pro is in the smoke list.
      • 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.
      • Pro UX pass (same date): usePersonalContext now decrypts the Stash on demand — unlockStash() is called by getMyStash when the AI actually reads it (stashRef avoids the stale-closure trap) — so "what's in my stash?" is correct, while the globally-mounted guide still never decrypts on mount. New self-contained action draft wtb_gear ("wanted" ads, NIP-99 listing_type=wtb). The panel header shows ⚡ Pro · Nd days left; the tier picker shows live USD via useExchangeRate (the hardcoded ~$60k/BTC usd field was removed from PRO_TIERS). Copy now says skate status (not spot status) and states trip planning is free. 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). Bubble visibility (2026-10-04): the floating Sasquatch FAB no longer pulses forever — the animate-ping halo shows only when an unread tip exists, is motion-reduce:hidden, and is suppressed on full-bleed routes (isFullBleed passed from Layout). The bubble keeps its full size/glow on every route (do NOT shrink it into a dull compact circle — that looked bad); the map only loses the pulse. Hide is a small ✕ shown on desktop hover, plus an EyeOff "Hide assistant" button in the panel header, plus useSasquatchHidden (sasquatch-hidden, same-tab synced via a sasquatch:hiddenchange event) and a Settings → Appearance "Show Sasquatch assistant" toggle. (Earlier iteration put an always-visible ✕ over the avatar on mobile — removed; it was ugly.)
    • 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). Phone navigation (2026-09-29): while following we also keep the screen awake (navigator.wakeLock, re-acquired on visibilitychange), speak turns via device TTS (speechSynthesis, prefer a localService voice in the user's language — free/offline/no server) at ~150 m and ~40 m bands (announced once each, tracked in announcedRef), buzz haptics (navigator.vibrate), detect arrival (~30 m → "You've arrived", auto-stop), and show a bigger map while following with the planning step-list hidden. Voice has a mute toggle persisted in skatespots:route-voice; TTS is unlocked by the Start tap. Still deliberately absent: auto-reroute if you leave the route. True screen-off/background navigation needs a native foreground service — the app must stay foregrounded to guide.
    • Dialog layout (2026-09-29): app-like flex column — flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden. DOM order now matches the visual order (and order-* is set redundantly, so a dev-mode CSS-timing hiccup can't scramble it): header, then the map directly under it (it's the point of the screen), then the mode/vehicle row, then the "From" row, then the scrolling details, with the Start/Stop bar pinned at the bottom. The map is shrink-0 at h-[34vh] min-h-[220px] sm:h-[400px], or h-[54vh] min-h-[320px]/sm:h-[60vh] while following. The summary is a compact one-line strip (distance · time · arrive) — StatTile was removed; the three ~90px tiles were eating the map's space. Owner feedback (2026-10-01): the map dominated the phone screen — it was 50vh (and 38vh before that); 34vh gives the itinerary and controls room while staying pannable, and follow mode restores a bigger map. The dialog's tagline ("In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps") was removed from the visible chrome (kept sr-only for a11y); that promise now lives on /privacy → "What leaves your device" → Directions. ⚠️ Don't go back to a single overflow-hidden content box: that made everything below the fold unreachable once the map grew. 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.
    • Transit detail (2026-10-01): Transitous returns more itineraries than requested (numItineraries: 4 → we saw 9), so fetchTransitRoute now maps them ALL into RouteResult.transitOptions (transit = index 0). RouteDialog shows alternatives (tap to switch — a separate effect redraws the map, no re-query) and a leg-by-leg itinerary: coloured line badge, headsign (→ S Strausberg Nord), board/alight stop + clock times, platform track when present, N stops (intermediateStops.length + 1), operator (agencyName), and a live badge only when realTime is true. Mode → icon/colour is MOTIS's own mode (BUS 🚌, TRAM 🚊, METRO/SUBWAY 🚇, RAIL*/REGIONAL_RAIL… 🚆, FERRY ⛴️). ⚠️ MOTIS labels a German S-Bahn METRO (verified live) — that's the feed's own classification; don't "correct" it heuristically. ⚠️ Transitous 403s a generic User-Agent ("Generic user-agent headers are not allowed") — the browser sends its own so the app is fine, but a Node/curl probe must set a User-Agent. Time picker + mode filter + stop boards (2026-10-01): fetchTransitRoute takes { time, arriveBy, transitModes } (MOTIS time/arriveBy/transitModes); the dialog exposes Leave now · Depart at · Arrive by (a datetime-local input) and an Only row of mode toggles (nothing selected = all modes). Mode groups live in src/lib/transitUi.ts (TRANSIT_MODE_GROUPS) with the shared mode→emoji/colour/label helpers — same module the board uses, so the vocabulary can't drift. Tapping a stop name in the itinerary opens StopDepartures.tsx (fetchStopDepartures → MOTIS /api/v1/stoptimes, n=10), showing clock, mode, line, headsign, a feed-provided platform hint (place.description, e.g. "S-Bahnsteig Gleis 3"), and live/cancelled badges. It is rendered outside the route dialog (a nested focus trap would fight). ⚠️ Fares do not exist in this data — do NOT invent prices: verified live that the plan response has no fare field (debugOutput.fares: 0, agencyFareUrl: ""). The only honest fare UI is a tickets ↗ link to agencyFareUrl when the feed supplies one (usually empty). Still absent: nothing planned.
    • ⚠️ 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.
  • DIY resources (/diy, 2026-10-05): a hub for making your own boards/ramps/concrete. Content is a seed list in src/lib/diyGuides.ts (DIY_CATEGORIES boards/ramps/concrete/repairs/tools + DIY_CARDS + DIY_ACTIONS); src/pages/DiyPage.tsx renders it. Policy: link to our own app sections (to) where we have content; otherwise show the topic as an unclickable comingSoon card — no external links. Registered as a nav-catalog item (id diy, Hammer) so it shows in the desktop "More" menu and the mobile burger, and is linked from the site footer + the map links strip. Cross-links to /lab?craft=building (Builder), /crews, /create-spot, /create-listing, /lab?craft=coaching, /marketplace. Deck cost calculator (2026-10-05): the first in-app tool lives at /diy/deck-cost (components/DeckCostCalculator.tsx) — a self-contained (localStorage) model of labour, equipment amortisation, operations, materials, process time, packaging and margin → selling price; the deckCost card links to it. Naming is deliberate (2026-10-05): the labour multiplier is "Labour burden (×)" (not "overhead" — rent/bills are the separate Operation expenses section; the hint warns against counting them twice), the derived wage × burden figure is the "Loaded rate", and the multiplier is "Markup (×)" with the implied gross margin % shown (it is not a margin). The three # boards columns are context-specific: Lifetime boards (equipment), Boards / order (materials), Boards / run (processes). CSV keys stay machine-stable (overhead_factor, profit_margin, …) for round-tripping. UX + batch/trade (2026-10-05): the long sheet is now an accordion (CalcSection — each header carries its live per-board subtotal; Labor open by default, Margins always visible) with quick-start templates (SHEET_TEMPLATES: Hobby starter / Small shop) and an amber sanity-warning block (markup < 1×, wholesale ≥ retail, rows with 0 boards, fixed costs with 0 boards/month, no materials, equipment+overhead > 60% of cost, equipment spread over 1 board). The result adds quantity (unit price vs order total), a wholesale markup → wholesale/unit price, profit/deck and a breakeven (fixed ÷ profit, plus a targetMonthlyProfit goal). New state quantity / wholesaleMarkup / targetMonthlyProfit round-trips through CSV (margins rows); numInput clamps negatives to 0. The client quote shows × N and the order total (the deposit is taken on the total). Advanced toggle (2026-10-05): a Simple ↔ Advanced switch (persisted skatespots:deck-cost:advanced) — deliberately not called "Pro" (that name is Sasquatch Pro). Advanced reveals four things: per-operation labour rate (ProcessRow.rate, 0 = shop rate) and per-operation scrap (ProcessRow.scrapPct) that compounds with the general scrap into an effective waste %; batch setup (batchSetupHours + batchSetupCost, a one-off per run amortised over quantity, so the unit price depends on qty); a tax / VAT line (taxPct + taxInclusive, shown on the client quote as Subtotal/Tax/Total or "includes X% tax"); and a cost-vs-quantity curve (1/5/10/25/50/100 decks). costWithPackUnit/costNoPackUnit include setup, and the prices use them. All four round-trip through CSV, and the state is merged over DEFAULT_CALC on read (old sheets/presets can't produce NaN — clampPct is NaN-safe too). Currency switching also converts targetMonthlyProfit, batchSetupCost and process rate. Polish (2026-10-05, part 2): the client-quote fields are a collapsed <details> ("Client quote details") so the always-visible Margins card stays short; CalcSection sets aria-expanded; every table row gains a duplicate (Copy) action beside delete; the sticky summary chip shows the order total when quantity > 1 (and uses the setup-inclusive cost); and the marketplace hand-off passes a buyer-inclusive unit price (tax added on top when tax isn't marked inclusive). Mobile layout (2026-10-05, part 3): the sheet tables (5–8 columns) were unusable on a phone — values truncated (900→90) and columns fell off the edge. On max-width:639px the 4 data tables become stacked cards via src/index.css .ss-sheet-table (thead hidden; each td a label/value row; data-label supplies the label through td::before; cells flex-wrap so a long label + input never overflows). The Cost-vs-quantity block is now a vertical list, not a table, so nothing scrolls sideways. Verify with a --window-size=390,… headless screenshot; add defaultOpen to a CalcSection temporarily to see a table expanded. Currency (2026-10-05): a searchable currency selector (Popover + search) covering sats + BTC (both Bitcoin units) and every fiat Coinbase reports (crypto is filtered out via FIAT_CODES), and switching converts every money field using live BTC rates (useBtcRates → getBtcRates, one Coinbase exchange-rates?currency=BTC call — all currencies, sats = 1e8; unitless fields are untouched). Falls back to relabel-only when rates are unavailable. Polish (2026-10-05): Setup (h) now feeds equipment amortisation ((cost + setupHrs × labor/hr) / boards); sats round to whole numbers; each section shows a per-board subtotal; a sticky summary bar keeps cost/price visible; Save/Load named presets + JSON Export/Import (localStorage + download/file), and Reset now confirms. The results include a "List a deck at this price →" link to /create-listing?price&currency (CreateListingPage reads both params) and "Get it made →" to /lab?craft=building. Export (2026-10-05): the primary export is a PDF (lazy-loaded jspdf) — a formatted summary of every section + the selling price; the reason it's a real PDF and not print-to-PDF is that window.print() doesn't work in the Capacitor WebView. A secondary JSON export remains (for Import/backup); Import reads JSON. The default Materials list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler, mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/ paint/clearcoat/grip). Export/Import (2026-10-05): Export ▾ — PDF (full sheet), PDF (client quote — uses a "Quote details" block: business name, a data-URL logo, email/phone/website/address, notes/terms, a quote # counter (Q-0007, with a +1 button) and validity days; shows logo + contact header, line item, total, Quote valid until <date> and notes, not the internal breakdown), PNG summary card (canvas — now also draws the logo + business name), XLSX multi-sheet workbook (lazy xlsx), CSV (section,item,field,value), Markdown, JSON (backup), Copy summary (clipboard). ⚠️ The quote identity lives in its own store (skatespots:deck-cost:quote, QuoteProfile), not in CalcState, so loading/saving presets keeps the same business name/logo/contact (the sheet is numbers only). The sheet's key is skatespots:deck-cost:v2. Nostr identity on the quote (2026-10-05): QuoteProfile also carries NIP-05, npub and a Lightning address (lud16); a "Fill from profile" button autofills them (plus name/website) from the logged-in kind-0 (useCurrentUser + useAuthor + nip19.npubEncode). The client PDF lists NIP-05 … / nostr npub… in the contact block and Pay via Lightning: <lud16> under the total; the PNG shows the NIP-05 (or npub) under the business name. A PDF is a snapshot — the identity is verifiable on Nostr, not on the paper. Payment terms on the quote (2026-10-05): QuoteProfile also carries payment methods (multi-select chips: Cash/Lightning/Cashu/Bank/Card/Other), a deposit % (default 50 — the PDF splits Deposit due now / Balance on completion), free-text payment terms and payment details (bank/PayPal, separate from the schedule), and an optional Incoterm (EXW/FCA/DAP/DDP — light, not a contract; the app still never handles shipping). Read with { ...DEFAULT_QUOTE, ...rawQuote } so a quote saved before a new field existed can't crash. Import auto-detects .json/.csv: a CSV whose header is name,order_cost,… is treated as a supplier price list (replaces the Materials table), otherwise it round-trips the whole sheet. Hand-rolled CSV parse/escape so commas in names are safe. Next: write our own step-by-step guides (NIP-23 30023 with t=diy) to flip the coming-soon cards live.
  • Deck designer — FROZEN / HIDDEN (2026-10-05): ⚠️ Not ready for regular users. It is not listed in the DIY hub (diyGuides.ts has a note where the card was) and /diy/deck-designer is admin-gated — DeckDesignerPage returns <Navigate to="/diy"> unless isAdminPubkey(user.pubkey), and shows an "Internal preview" badge. To un-freeze: remove the guard in DeckDesignerPage.tsx and re-add the deckDesigner card in diyGuides.ts. The i18n keys (diyUi.cards.deckDesigner.*, diyUi.designer.*) are kept. The code below is otherwise intact. Parametric 2D outline + 1:1 template — src/lib/deckDesign.ts (pure maths) + components/DeckDesigner.tsx + page. Inputs: width, wheelbase, nose, tail (length is derived as nose+wheelbase+tail), waist, end-roundness, concave, thickness, tail/nose kick, and the truck-hole pattern — New school 53.975 × 41.275 mm (2.125″×1.625″) or Old school 63.5 × 41.275 mm; 3/16″ (r 2.38 mm) holes. Exports: SVG at true mm (width/height in mm → prints 1:1), tiled 1:1 PDF (A4/Letter, split across sheets with corner registration crosses — doc.lines deltas, no path-API dependency, ensureRoom guards an overflow), and DXF (R12, $INSUNITS=4, outline polyline + hole circles + centreline). ⚠️ Deliberately 2D — this is the template a builder traces and cuts, not a SK8CAD clone. SK8CAD is proprietary (its Terms forbid copying/redistribution); only its engine, JSCAD, is MIT. We did not copy or self-host it, and we don't link out (DIY policy = no external links). If full 3D/mold CAD is ever wanted, the honest route is a JSCAD-based generator we own (CSG → STL, not STEP) — see the decision in the session notes.
  • 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). Community confidence aggregation (2026-10-03): src/hooks/useSpotCommunityScore.ts does ONE batched query for a spot's engagement (photos 1063, status 2259, comments/tag-suggestions 1111, reactions 7, zaps 9735, collections 30003), counts distinct accounts (never the mapper), tier-weights costly signals (zap 3, photo/status 2, rest 1) with a per-account cap of 3, and multiplies by each actor's web-of-trust + real activity (useBatchTrustScores/useBatchActivity) so a ring of empty accounts can't inflate it. Returns level: unverified | seen | community + breakdown; SpotDetailPage shows a "why" line (2 photos · 1 zap …). 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.
  • Profile badges (2026-09-28): lib/badges.ts + components/ProfileBadges. Two sources: (1) the built-in SkateSpots badge — derived from a nip05 ending @skate-spots.org (app logo → https://skate-spots.org/), so every issued handle gets it with no storage and it works for existing users; (2) user badges from a kind-0 badges array ({label, url?}), rendered as clickable chips (external links). The legacy newline sponsors field is carried forward into badges (parseBadges reads it; EditProfileForm now writes badges and deletes sponsors). Shown on the profile page (NIP19Page): the app badge in the header, user badges in a "Badges" card. ⚠️ NostrMetadata has no badges field, so it's read via a cast (metadata as … & Record<string, unknown>), like sponsors/cordn before it.
  • Report → action + a publish floor (2026-09-28): the one-tap "not a skate spot" report used to only land in the admin queue (invisible effect). It now shows on the spot page to everyone ("Reported as not a skate spot by N — under review"), and useAutoHiddenSpots hides a spot from the map when ≥3 DISTINCT accounts reported it and it has no community signals (no community photo, no status report). The "no signals" clause is the anti-brigade guard; it is reversible (a photo/status, or an admin cleared/verified verdict, brings it back). useSpotReports now exposes reporters (distinct accounts). Publish floor: a spot now needs a photo OR a non-empty description (title + type alone no longer publishes) — photo stays optional, per the earlier decision. And the flattering "Conditions … NN/100" block is hidden until the spot is confirmed (community or staff); on an unconfirmed pin it read as endorsement of the spot.
  • Private-spot ownership claims (2026-10-02): a private spot (access=private) can't be domain-verified like a shop, so ownership is NIP-05 + moderator confirm, never an instant takeover. Claimant (must have a NIP-05) publishes a kind-1985 label in the separate org.skatespots.spotclaim namespace; an admin publishes claim-approved (moderation namespace, a=coord + p=claimant); the claimant then re-publishes the spot with claim-reason=private-approved to become the owner. useSpotClaims.ts (useSpotClaims/useApprovedSpotClaims/ useAllSpotClaims/useSubmitSpotClaim) + useAdminApproveClaim; UI in SpotOwnership.tsx (private branch) and a pending-claims list atop /review. ⚠️ Proves key control + NIP-05, not property ownership. Non-private spots keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
  • Photo + description are required again (2026-10-07, reverses 2026-09-28). Owner decision: the create-spot form now requires ≥1 photo AND a non-empty description (on top of name/type/location) — the empty husk / one-word test pin can't publish. ⚠️ This is a junk filter, not sybil defence: a bot can upload a random image; the real sybil levers remain PoW, WoT weighting and the ≥3-distinct-reporter auto-hide. The 2026-09-28 "photo recommended, not required" decision is superseded — do not revert. No-photo publishing is gone.
  • Add-spot flow overhaul (2026-10-07). Three parts, one commit: (1) Map tap no longer jumps you into the form. MapPage.handleMapClick drops a pendingAddPin (rendered by SpotMap's pendingPin prop) and shows an "Add a spot here?" card; only its Add spot button navigates to /create-spot?lat=&lng=. (2) The form has a real pin map — src/components/SpotLocationPicker.tsx (tap to drop, drag to fine-tune, cached tiles). createSpot.pickOnMap was an orphaned key — the map picker had been intended all along; raw lat/lng now live behind an "Enter coordinates manually" toggle. (3) Progressive disclosure + inline errors + draft autosave: required fields (map, name, description, type, photo) are up front; access/contact/shelter/ tags/address sit under a More details collapsible (access defaults public, shelter defaults outdoor); field errors render inline (no toast per field); and the form autosaves via useFormDraft (generalized from useLabDraft, same resume-banner pattern). ⚠️ Leaflet's base CSS is CDN-loaded in index.html (src/main.tsx just has a comment) — a new Leaflet map needs no CSS import. Follow-ups in the same pass: the 60 m duplicate check now runs at pin-time (debounced, warning under the map) using the same useSkateSpots(2000) cache key as the map (was a second useSkateSpots(500) query); and publish caps the reverse-geocode at GEOCODE_BUDGET_MS (2.5 s) so a slow provider can't stall signing (a miss falls back to the stats' read-time region derivation). ⚠️ A geohash-prefix nearby query is still impossible — relays only exact-match #g — so the 2000-spot cache remains the mechanism. Required set (2026-10-07): pin + name + description + address + access type + photo. Address and access type were moved OUT of the "More details (optional)" collapsible into the required flow (the collapsible now holds only Shelter + Tags) — required fields must not be hidden under "optional". Access keeps its Public default (so it is always satisfied; the * marks membership in the required set), and Address has real inline validation (createSpot.addressRequired).
  • Map pin rendering (perf, 2026-09-29): the spot/tour/event/crew marker effect in SpotMap.tsx used to clearLayers() + rebuild every marker whenever the parent handed it fresh arrays, and added each marker with a per-marker clusterGroup.addLayer() (each call re-clusters the whole set) — several synchronous full rebuilds per load on a few-hundred-pin map. Now: (1) a marker signature (group geohash + spot/listing event ids + weather colour/icon + layer toggles) short-circuits the effect when nothing that affects the rendered pins changed — ids are enough because spot/listing/tour/ event/crew are all addressable (a new id on every edit), so unrelated array churn (trust scores, comment/zap totals, re-sorts) no longer rebuilds the map; (2) markers are collected and added with the plugin's bulk addLayers() in requestAnimationFrame batches of 150, cancelled on effect re-run. One cluster refresh per batch, pins paint progressively, main thread stays free. If you change what a marker renders, add it to the signature or the update will be skipped. showSpots/showShops/showGear are now effect deps.
  • On-device OS notifications (Phase 1, 2026-09-29): src/components/OsNotificationBridge.tsx (mounted once in Layout, inert until opted in) mirrors the in-app activity — social (useActivityNotifications), crews (useCrewNotifications), newsletter (useNewsletterIssues) and DM gift-wrap ENVELOPES (useDmEnvelopes, no decrypt) — to the OS shade. Delivery is src/lib/osNotify.ts: web Notification API, native @capacitor/local-notifications (lazily imported; the dep was added so cap sync registers it — an installed APK must be rebuilt to pick it up). Prefs are device-local (useNotificationPrefs, key skatespots:notifications:prefs, opt-in, default OFF); UI is Settings → Notifications (NotificationSettingsCard). Hard rules, don't regress: (1) never decrypt to notify — DM alerts are envelope-only, so Amber is never prompted just to say "you have a message"; (2) per-source silent PRIMING + a seen-id set (skatespots:notifications:seen, ×600) so opening the app never replays history as a flood; (3) suppressed while the document is visible (the bell already covers that). Delivery is local only — a fully closed app/browser is unreachable without a server push. Phase 1b — scheduled reminders (2026-09-29): lib/reminders.ts + useLocalReminders hand the OS an alarm 1 h before each upcoming event the user RSVP'd going/interested. Native-only; the hook is mounted from the bridge only when prefs.reminders AND isNativePlatform(). Because the OS holds the alarm it fires with the app fully closed — the only closed-app notification achievable without a server. Scheduled ids live in skatespots:notifications:reminders; the hook cancels them all on unmount (opt-out / logout). New pref key reminders (default on). Pro-expiry reminder deliberately skipped — it would pull in the heavy useSasquatchPro query. ❌ Phase 2 (background runner) is DEAD — don't attempt it (verified 2026-09-29): Capacitor's Background Runner supports fetch but NOT WebSocket (v8 docs), and Nostr relays are WebSocket-only, so a runner physically cannot query relays while backgrounded. On-device background relay polling is not possible. Closed-app DMs/invites therefore need either (a) a push gateway (FCM; a server learns metadata "this pubkey has something" — content/sender stay hidden by NIP-17, timestamps are randomised) or (b) a foreground service keeping a native WS alive (no server, but a permanent notification + battery + custom native code). Owner decision (2026-09-29): HOLD at Phase 1 + 1b — the zero-server design stands and closed-app DMs/crew are a documented limitation, revisited only if it becomes load-bearing. useResolvedDmRelays is exported for the envelope hook.
    • Push service built — the HOLD is lifted in principle (2026-10-01). Proven end-to-end in a local harness (marked wrap → relay → keyless watcher → Web Push → OS toast with the tab closed → tap opens the app), then built as push-service/ — our own Bun service (NOT Linky's; no Effect/linkstr dep) with web-push. ⚠️ Never use Linky's ["linky","push"] marker — ours is ["skatespots","push"] on the recipient copy only (plaintext on the wrap; unpushed wraps are ignored). Endpoints: GET /health · GET /vapid-public-key · POST /auth/challenge → POST /subscribe//unsubscribe (NIP-98 kind-27235 proof with a one-time challenge tag). State = JSON file; VAPID keys from env. Wired like the other standalone servers: typecheck:push + check:push (both in npm test), deploy:push (scripts/deploy-push.sh, generates VAPID keys on the host), Caddyfile
      • systemd unit in push-service/. ⚠️ Relay overlap is the trap: the marked wrap must land on a relay the service watches — designate the community relay as the notify relay and publish push-marked recipient copies there. ⚠️ Privacy: the server sees metadata only (pubkey ↔ push endpoint, timing); it never decrypts; disclosed on /privacy. Client half done: public/push-sw.js (service worker), src/lib/pushService.ts + src/hooks/usePushNotifications.ts (register + subscribe with a NIP-98 challenge proof), a Background notifications toggle in NotificationSettingsCard, and useSendDirectMessage now marks the recipient wrap ["skatespots","push"] and adds the community relay as the notify relay. The service also pushes marketplace events addressed to subscribers — offers (30403, the whole negotiation), requests (31872), confirms (31871) — via #p filters built from the live subscription set (so it only sees events for subscribers; the payload is a generic body, never the amount/item). ✅ DEPLOYED (2026-10-01): npm run deploy:push → the CVM host, systemd unit skatespots-push (/home/ubuntu/skatespots-push; VAPID keys generated on the host into .env). Public TLS: push.skate-spots.org (Netlify DNS A → 185.18.221.2, and a site block appended to the host's /etc/caddy/Caddyfile — NEVER overwrite it, it also serves ai.skate-spots.org). Verified https://push.skate-spots.org/health → 200. ⚠️ 000 from this box = a stale local resolver cache (NXDOMAIN); confirm with curl --resolve push.skate-spots.org:443:185.18.221.2 …. Remaining: native closed-app push. ⚠️ Google/FCM is REJECTED — owner decision (2026-10-01). The FCM/@capacitor/push-notifications work was reverted (commit 68c12a2); do NOT re-add Firebase/google-services. Web push (VAPID) is live and unaffected. For the APK the only no-Google options are: (a) UnifiedPush (FOSS distributor the user installs — e.g. ntfy / NextPush) or (b) a native background poll of our own service. Chosen: (b), the Cordn pattern — Cordn (Cordn-msg/cordn-web, package cordn-background) does native background polling via a custom Capacitor plugin (Kotlin + Rust/UniFFI) with a standard (WorkManager ~15 min) and a fast (foreground service ~5 min) mode, polling its ContextVM coordinator and posting local notifications — no Google. We need less: our notifications are already content-free, so no Rust/decrypt is needed. Server side DONE + verified (2026-10-01): push-service has /native/poll/register (NIP-98 → poll token; the pubkey is added to the relay watch), GET /pending?token&since → { seq, at, changed } (a content-free cursor), and /native/poll/unregister; pushTo() calls recordActivity(). Plugin WRITTEN (2026-10-01): packages/skatespots-background/ — a local Capacitor Android plugin (Kotlin: SkateSpotsBackgroundPlugin, PollWorker [WorkManager], PollService [foreground], Poller, Notifier, PollPrefs) plus src/hooks/useBackgroundDelivery.ts and a Background delivery (Android) control in NotificationSettingsCard (Off / Standard / Fast). The app registers it by NAME via @capacitor/core — it does not import the package (no web-bundle dep); only the Android shell does, via the file: dep + cap sync. ⚠️ UNVERIFIED — needs cap sync + a device. android/ is regenerated, so the file: dep must stay in package.json for cap sync to add the module; build.gradle/manifest may need tuning per the app's Capacitor version. Category-aware (2026-10-01): /subscribe and /native/poll/register carry categories; the server stores them per subscription/token and only pushes matching ones (DM gift-wrap → dms; offer/counter/accept/attestation/ shipment → trades — social/crew/newsletter are in-app only, never pushed). /pending uses a per-category activity breakdown, so it only moves for the token's categories. NotificationSettingsCard re-sends the choices on toggle change (push.resubscribe() / background.resubscribe()). ⚠️ An absent categories = legacy (allow all); an empty array = none. ⚠️ Android standard mode is best-effort (Doze/OEM battery managers defer it); fast needs a sticky notification. Never the only path. (Approach
      • tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.) Categories PUSHED (2026-10-01, supersedes the line above): dms, trades, crew (invite 36229 / join-request 31969), and social (zap 9735 / reply-mention 1 / comment 1111) via CATEGORY_BY_KIND. Reactions/reposts are NOT pushed (too noisy) and newsletter has no push channel — those stay in-app. The master switch off tears down push + the native poller (toggleEnabled(false) calls push.disable() + background.disable()).
  • Cordn coordinator support (client-side, 2026-09-29): Cordn is an MLS delivery coordinator exposed as a ContextVM server over Nostr relays (E2EE; it sees ciphertext + metadata — who/when/which group). SkateSpots is a Cordn client only — we do not embed a coordinator in the APK and we don't run the default one (liability/centralisation — deliberate). Formats are taken from Cordn itself, not invented: a coordinator is an nprofile (pubkey + relay hints); its canonical add-link is https://cordn.net/chat/coordinators?c=<nprofile> (Cordn packages/server/src/main.ts); default relay wss://relay.contextvm.org; self-host is the docker run … ghcr.io/cordn-msg/cordn:latest one-liner. Files: src/lib/coordinator.ts (parse/encode/add-url/name), src/hooks/useCoordinator.ts (device-local store key skatespots:coordinator
    • useCoordinatorHealth), src/components/CoordinatorSettingsCard.tsx, Settings → Coordinator (?section=coordinator). Health is a presence probe, not Cordn RPC: query the coordinator's relays for authors:[pubkey] (its ContextVM announcement/heartbeat) — so an unannounced/private coordinator honestly shows "Not seen". "Run your own" is a hand-off (link to Cordn + copy Docker + scan its QR), never an embedded runtime — the phone-hosting story belongs to Cordn's own app. ⚠️ CORDN_APP_URL currently points at cordn.net; the exact Android-app deep link is unconfirmed — fill it in when known. Profile discovery (built 2026-09-29): the carrier is the existing kind-0 cordn field (app convention, documented in NIP.md — not a new kind). EditProfileForm accepts a pointer (nprofile / cordn.net link / npub / hex / http URL), normalises a bare pointer to Cordn's canonical add-link before saving (coordinatorAddUrl), and has "Use my coordinator". NIP19Page renders it as a Coordinator row with a one-tap Add (sets the viewer's useCoordinator, with toast) plus an "Open in Cordn" link — falling back to the old plain-link row when the value doesn't parse. ⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it (2026-09-29): src/lib/cordn/{client,crypto,endpoint,types}.ts + useCordnChat.ts + CrewChat.tsx + crew/CrewCoordinatorCard.tsx, driven by a crew's coordinator tag ['coordinator', <url>, <coordinatorPubkey?>, <operatorPubkey?>] (SkateCrew.coordinator, parsed in types.ts, resolveCordnEndpoint() → CordnEndpoint), documented in NIP.md ("Private Crew Chat (Cordn / MLS)"). Every crew already has built-in NIP-17 E2E chat; the tag upgrades it to sealed NIP-44 over ContextVM. The only gap bridged this session: the crew form accepted a URL only, so a coordinator run from Cordn's phone app (an nprofile, not a URL) was un-enterable — CrewForm now uses the shared parseCrewCoordinatorInput / crewCoordinatorInputValue (lib/coordinator.ts) and has a "Use my coordinator" button that prefills from Settings → Coordinator. ⚠️ Transport reality (do not overclaim): a crew's wss:// coordinator URL is used AS A RELAY (resolveCordnEndpoint → relayUrl); an https:// one falls back to the public wss://relay.contextvm.org. The app publishes sealed kind-4497 events and queries them back — it does not call Cordn's coordinator RPC (FetchGroupMessages/SubscribeGroupMessages), so a real Cordn coordinator is not yet engaged, and an https:// coordinator puts ciphertext + metadata (crew coord, timing) on a public relay. CrewCoordinatorCard + CrewForm now say this honestly (2026-09-29). Still future: real MLS (ts-mls) instead of group-key crypto, plus Cordn RPC interop. Lesson: grep src for the feature before building it — this was nearly duplicated.
  • Map site-links strip (2026-10-05): the desktop footer is hidden on the map (full-bleed), so components/MapFooterStrip.tsx surfaces the same links (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an overlay inside the map container: a slim full-width bar on desktop and a tiny collapsed "Links" chip on mobile (expands to a compact scrollable row). Dismissible via useMapFooterHidden (skatespots:map-footer-hidden, same-tab synced), restorable in Settings → Appearance. The map's OpenStreetMap attribution moved to md:bottom-7 so the bar doesn't cover it.
  • Map layout — controls float (2026-09-29): on mobile the MapPage toolbar is absolute over the map (pointer-events-none wrapper + gradient + pointer-events-auto inner) so the map fills the whole container; md:static restores the in-flow bar on desktop. The container height subtracts header + bottom nav only (the old desktop --footer-desktop subtraction is gone) and the desktop footer is hidden on full-bleed routes (Layout footer: isFullBleed ? 'hidden' : 'hidden md:block'). Result at 390×844: map starts under the header and is ~86% of the viewport (was ~78%). Don't re-add a stacked toolbar or the footer subtraction. The "New here?" welcome banner (logged-out only) still occupies ~69px in flow on first visit — it's dismissible and the dismissal is persisted (skatespots_welcome_dismissed). ⚠️ The banner MUST be the first child of the map container, ABOVE the toolbar (fixed 2026-10-01). The toolbar is absolute top-0 inside the same container, so as a later sibling the banner sat at the container's top and the floating toolbar painted over it (owner screenshot: search bar covering the banner). Now the banner is first and the toolbar drops into flow (showWelcomeBanner ? 'relative border-b bg-black/95' : 'absolute …gradient') so it can't cover it; md:static is unchanged on desktop. ⚠️ The floating toolbar sits over Leaflet's default top-left zoom control, so MapPage passes className="h-full ss-map-floating-toolbar" and index.css offsets .leaflet-top.leaflet-left by 4.25rem only under max-width:767px (desktop toolbar is in flow, no offset). Verified at 390×844: zoom top 135px vs search bottom 108px.
  • Signup & onboarding UX pass (2026-10-01): hardening the two highest-stakes first impressions.
    • Signup is one fork. LoginArea also rendered a SignupMethodDialog ("Recovery Phrase vs Secret Key") + MnemonicSignupDialog, but setSignupMethodDialogOpen(true) existed nowhere — the whole method branch was DEAD code (unreachable). Removed the wiring (files kept on disk); the misleading nsecSignupDialogOpen state was renamed signupDialogOpen. SignupDialog (welcome → profile → done) is the only signup path.
    • SignupDialog gained a step indicator (STEP_ORDER/STEP_LABELS: Choose → Profile → Save key) and makes backup a confirmation, not a skip: a checkbox ("I've saved my recovery code…") enables "Let's skate"; the Download button also sets it. The old co-equal "Skip for now, skate" is now a quiet text link ("I'll save it later from Settings"). Welcome copy no longer claims "nothing to remember" (it contradicted step 3), the profile step warns a signer may ask once, and non-Android copy points at signer apps instead of implying a recommendation.
    • Onboarding adds "Where do you skate?" (OnboardingPage): for a logged-in skater with no cached fix, "Use my location" calls getCurrentPosition() (which also saveLastLocation()s), so MapPage's mount-time getLastLocation() opens centered on their area. The logged-out wallet section's dead disabled "Log in first" button became a plain line.
  • 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).
  • Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01): two Nostr-native auth upgrades (owner picked these two; read-only/npub sessions were explicitly declined).
    • NIP-49 ncryptsec — src/lib/ncryptsec.ts. A password-encrypted key, used BOTH as a login method (LoginDialog → Secret Key tab → "Encrypted (NIP-49)": paste ncryptsec1… + password → decryptNcryptsec → login.nsec) AND as the signup backup (done step → "Save an encrypted backup" → password → downloads a password-protected file — a safer artefact than a raw nsec). Thin wrapper over nostr-tools/nip49 (import * as nip49 from 'nostr-tools/nip49' — not exported from the package root; log_n = 16). Verified in Node: round-trips, wrong password rejected by the AEAD, non-ncryptsec1 input rejected, empty password rejected.
    • NIP-46 nostrconnect:// QR — src/lib/nostrConnect.ts. The missing "log in on desktop with the key already on your phone" flow. ⚠️ Nostrify does NOT implement this direction — it only has NLogin.fromBunker / NConnectSigner, where the client already knows the signer pubkey and sends connect. nostrconnect is the reverse: the SIGNER sends the first connect request to the client's ephemeral pubkey and the client must reply with the shared secret. startNostrConnect(nostr, …) mints an ephemeral keypair + secret, builds nostrconnect://<clientPub>?relay=…&secret=…&name=…&url=…&image=…, subscribes to kind 24133 #p:[clientPub], and on the signer's connect replies {id,result:secret} — signed with the ephemeral key (finalizeEvent + nostr.relay(url).event(signed); the pool's signer is the logged-out user, so it must NOT be used). It then hands the derived bunker:// URI to the existing login.bunker() path so the session reuses well-tested code. ⚠️ Transport encryption: try NIP-44, fall back to NIP-04, mirroring whichever the signer used (NIP-46 moved NIP-04 → NIP-44; support varies). Default relays wss://relay.nsec.app, wss://relay.damus.io, wss://nos.lol. UI: LoginDialog → Bunker tab leads with the QR; the bunker:// paste stays as the fallback. ⚠️ NOT verified against a real signer — needs an on-device check with Amber (scan → pair → signed in); only the URI shape was verified in Node.
  • Mobile grid overflow gotcha (2026-10-04): a Tailwind grid must declare a base column — grid grid-cols-1 md:grid-cols-2, not grid md:grid-cols-2. Without the base, mobile renders one implicit auto track that can exceed the container, so a long name (e.g. a /lab ProfileCard, or /stats Top Mappers) pushes the whole card off the right edge and clips the values. Fixed repo-wide; also add min-w-0 to truncating flex/grid children. /stats also gained a Top Shops leaderboard (topShops in useGrowthStats; PERSIST_BUSTER → v6) alongside Top Mappers (spots).
  • Dropdown menu scroll gotcha (2026-10-08): the shared DropdownMenuContent (components/ui/dropdown-menu.tsx) shipped overflow-hidden with no max-height, so any menu taller than the viewport was clipped and could not scroll to its last items (the language picker, now 22 locales, was cut off at the bottom). It now carries max-h-[var(--radix-dropdown-menu-content-available-height)] + overflow-y-auto overflow-x-hidden (canonical shadcn). Radix exposes that var but nothing applies it. Keep it if you add more long menus. The language picker (LanguageToggle) also gained a search input in both the header dropdown (sticky above the scrolling list; input stops keyDown/pointerDown propagation so Radix typeahead does not hijack typing) and the inline/mobile variant; keys nav.searchLanguages/nav.noLanguages live in en/es only (other locales fall back). The Settings LanguageSelector grid is searchless by design.
  • Community stats (/stats, 2026-10-01): useGrowthStats + CommunityStatsPage.
    • ⚠️ A relay outage used to render a board of ZEROS as truth — every sub-query ended in .catch(() => []). The queryFn now counts failures (a tracked querySkateContent wrapper) and returns partial: boolean; the page shows "Couldn't reach every relay — these numbers may be incomplete". Don't drop this: zeros must never be presented as real numbers.
    • New data: marketplace — live listings (30402, #t:['skateboarding'], showcase=true excluded), completed trades (31871 from useTradeAttestations, s=revoked excluded), tradedSats summed from the self-reported amount tag, tradesByMethod; regions — topCountries/topCities built from each spot or shop's own city/country tags. Anything without one is surfaced as an uncategorised row, and the footnote states the denominator ({uncategorised} of {locationsTotal}), so the card reconciles with the location total instead of silently not adding up. Countries are grouped by a stable key: the country_code the create-spot geocoder now also publishes (ISO-3166-1 alpha-2, e.g. GB), else a lowercased name; the label shown is the most common raw name in the group, so a localised spelling can't split one country into two rows. The tail beyond the top 6 is surfaced as an otherCountries/otherCities "Other" row, and the heading reads "Spots by Region" — it counts spots, not people; and a bounded zap signal — zapCount/zapSats from kind-9735 receipts #p the top ~25 contributors (amounts decoded with getSatoshisFromBolt11). ⚠️ Global zap volume is NOT measurable on a public relay, so the code and copy say "not global volume" — never relabel it as the community total.
    • UX: the badge shows "updated Nm ago" + Refresh (dataUpdatedAt/refetch); the 13 Lab cards collapse on mobile (a display:contents wrapper toggled hidden lg:contents, so children stay real grid items) with a "Show all 13 crafts" toggle; container widened max-w-6xl → max-w-7xl; StatCard gained a compact prop (+compactNumber) so big sats don't overflow text-4xl.
    • ⚠️ Adding a field to a PERSISTED query is a crash risk (2026-10-01). growth-stats is in the queryPersister allow-list, so a cache saved before a new field existed lacks it — and stats.topCountries.length throws (Cannot read properties of undefined), slamming /stats for anyone with an old cache. Fix: bump PERSIST_BUSTER (src/lib/queryPersister.ts, wired in App.tsx) AND read defensively (stats?.x ?? []). ⚠️ The smoke test cannot catch this — it boots a clean profile with no persisted cache, so it passed while real users crashed. Any new field on an allow-listed query needs both.
    • ⚠️ The map and the stats legitimately differ — do NOT "fix" the map to queryCanonical (2026-10-01). The map (useSkateSpots → querySkateContent) reads the community relay + the reader's own relays; the stats read the community relay only (queryCanonical). Forcing canonical on the map would hide spots for a user who opted out of the community relay (useSkateRelay=false) — including their own. Two more reasons a "23" cluster ≠ a country row: a cluster bubble is proximity (may span countries), and the region card only counts locations with a location tag while the map draws every pin. The number comparable to the map is the hero "Skate Spots" card (totalSpots), not the region rows.
    • Moderation now applies to the stats too (2026-10-01): useGrowthStats calls useAdminModeration() and drops hidden content inside dedup(), so a spot the community removed no longer inflates totalSpots, the regions, or the trusted counts. The hidden-set sizes are part of the query key so the stats recompute once the labels load. Remaining map↔stats deltas (both legitimate): untagged pins (the map draws them, the region rows only count tagged ones) and auto-hidden spots (the map drops them at MapPage:847; the stats can't know about reports without querying them).
  • Anti-fake-volume on /stats (2026-10-01): the marketplace "Sats Traded" used to be a naive sum of the self-reported amount tag on 31871, so two colluding accounts could inflate it for free. useGrowthStats now computes tradedSats robustly — mutual trades only (a listing attested by ≥2 distinct pubkeys), per-pair cap 3, amounts >1 BTC dropped as junk — plus tradedCount (mutual trades) + tradedTraders (distinct people), and keeps tradedSatsClaimed (the raw claim) so the card shows both. New query fields ⇒ PERSIST_BUSTER → v5. There are no trade attestations on the relay yet, so the honest values are 0 today. Related: useTradeReputation now exposes withPaymentProof — mutually-confirmed trades that carry a proof tag — surfaced on TradeReputationCard as "with payment proof (self-reported — not verified)", never as verification. The one verifiable tier (2026-10-01): ["proof","zap","<receiptId>"] is checked by src/lib/zapReceipt.ts (verifyZapReceipt: kind-9735 signature + p = seller
    • decodable bolt11 amount + the embedded 9734 request) and counted as paymentVerified — shown as "payment-verified (zap receipt)". Honest ceiling: it proves the seller's LNURL provider acknowledged a payment, not that goods changed hands. (Remaining anti-sybil: ring detection + PoW.)
  • Reverse geocoding hits Nominatim — disclosed (2026-10-01). src/lib/geocodeRegions.ts (reverseGeocodeRegions / geocodeBigDataCloud / pushRegionTags) is the single source of truth, used by spot create, spot edit, shop create and shop edit. reverseGeocodeRegions() tries nominatim.openstreetmap.org/reverse?lat=…&lon=… (Accept-Language: en) first, then falls back to BigDataCloud when Nominatim returns nothing (it 429s — see the derived-regions note above), so a new spot is tagged by default. It never throws (a geocoder outage can't block a publish; the spot still gets its geohash and the stats' read-time derivation can place it). Both providers see the creator's IP and are disclosed on /privacy → "Spot/shop geocoding". Coordinates are the pin you placed, never the device's live GPS. ⚠️ Any new outbound call that receives coordinates must be added to /privacy in the same change. ⚠️ EditSpotPage used to DROP region tags (fixed 2026-10-01): like EditShopPage it rebuilds eventTags from scratch, so any city/country/ country_code the spot already had were silently lost on save (the new addressable event replaced the old one without them). Both edit pages now reverseGeocodeRegions() on save. Any page that republishes an addressable event must re-derive or preserve its region tags — check this whenever a new editor is added. Related: spots created before the tags existed stay "Uncategorised" in /stats until they're re-saved (only the author can update an addressable event).
    • Admin backfill for legacy untagged pins (2026-10-01): the reason /stats "Spots & Shops by Region" under-counts vs the map is tag coverage, not a stats bug — the map draws a pin by its coordinates, the region card can only place a pin that carries a city/country tag. Measured on the canonical relay: raw 54 spots+shops → 43 after admin-hide → 13 tagged / 30 "Uncategorised", and 21 of those 30 are the two admin accounts' own pre-geocoding pins. So useRegionBackfill.ts + RegionBackfillCard (Settings → Advanced, admin-only) find the logged-in user's OWN untagged spots/shops, decode each pin's coords (lat/lon else g), reverseGeocodeRegions() them (spaced 1.1 s for Nominatim's 1 req/s policy), and republish the addressable event with the region tags added — every existing tag preserved, only stale city/country/ country_code replaced. Publishes to getWriteRelays() (community relay
      • author) so canonical queryCanonical stats see it, then invalidates skate-spots + growth-stats. Only the author's key can update an addressable event, so each admin must run it while logged in as themselves; the 9 remaining untagged belong to 9 distinct community mappers and can't be fixed centrally. ⚠️ Even after backfill, a map cluster bubble ≠ a country count — bubbles are 60 px proximity (markerClusterGroup) and can span borders, and the map also plots shops/gear/tours/events/crews the region card doesn't count.
    • Read-time derived regions (2026-10-01): so the region card matches the map with NO signing, useGrowthStats falls back to a cached reverse geocode of an untagged pin's own coordinates (lib/derivedRegions.ts, localStorage skatespots:derived-regions-v1, keyed by ~110 m-rounded coords). A useDeriveRegions() hook mounted on /stats scans the community relay for untagged spots/shops, fills cache misses, and invalidates growth-stats every 5 resolved (and at the end); the card shows a live "Resolving N…" counter. ⚠️ Do NOT use Nominatim for this — it is per-VIEWER volume (every device, every reload), Nominatim's policy forbids it and it 429'd us (2026-10-01), which silently broke every remaining lookup (reverseGeocodeRegions() returns empty on !res.ok, so the counter froze and the card stalled). The fallback now uses BigDataCloud reverse-geocode-client (no key, CORS * on both api.bigdatacloud.net → 307 → api-bdc.io, English countryName/countryCode/ city), spaced ~300 ms. Nominatim stays for create/edit only (the author's own pin, one call per spot). ⚠️ This geocodes pins when the stats page is viewed, so the provider sees the viewer's IP + public pin coords — disclosed on /privacy ("Spot/shop geocoding", names both providers). It is a display fallback only; the backfill above writes real city/country tags. Either way the card's Uncategorised row now means "couldn't be tagged OR resolved".
  • 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.
  • Deleted content flashed back from cache (fixed 2026-10-09): on refresh, a just-deleted spot (e.g. the Africa test pins) appeared for a few seconds and then vanished. Two cache layers render before "current truth": the persisted React Query cache (skate-spots/skate-shops are allow-listed in queryPersister.ts) and the Dexie spot cache (offlineCache.ts). A NIP-09 kind-5 delete is honoured only by relays and is never applied locally, and some relays ignore it — so cached copies kept resurfacing until the fresh query (or the useAdminModeration hidden-label query) filtered them out. Fix: src/lib/localDeletions.ts — a device-local tombstone (skatespots_local_deletions_v1) keyed by BOTH coord (kind:pubkey:d) and e:<id>, written optimistically in useDeleteEntity on the owner path (rolled back if signing/publish fails). The shared filterAdminHidden / filterAdminHiddenMixed now also drop tombstoned items, so every list hook (spots/shops/events/tours/crews/marketplace/skaters/coaches/lab) filters them from the first render — no per-hook changes. useSkateSpot returns null for a tombstoned spot. cacheSpots/cacheSpot refuse to re-cache tombstoned rows and evict existing ones. ⚠️ Do NOT prune the Dexie cache by "not in the fresh result": callers use limits from 8 to 2000, so a small-limit query would wipe valid cached spots — prune by tombstone only. Admin hide was already instant via localModeration's local verdicts; this closes the NIP-09 self-delete gap.
  • 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". ⚠️ Two distinct signals (fixed 2026-09-29): the saved-spots banner is driven by deviceOffline (!useOnlineStatus()) only — it used to also key off the single SKATE_RELAY probe, but the map reads from several relays, so it stuck around online while fresh data loaded fine, and its retry never re-probed. The banner is also dismissible (re-armed when the connection returns). The relay probe still gates the "nothing at all" card (relayUnreachable = !online || relay.status === 'offline'), and both retries now call refetchSpots() and relay.recheck().
    • Offline WRITE queue is now real (2026-09-29). lib/offlineCache already had a pendingPosts table + useSyncPendingPosts/useQueuePost + an OfflineIndicator badge, but nothing ever called queuePost — offline writes just threw. Now: useNostrPublish queues on isOffline() (skipping the 15s publish timeout) or when every relay fails, storing the already-SIGNED event (queueSignedEvent) so an interactive signer is never re-prompted at sync time; useSyncPendingPosts publishes it directly (nostr.event), not through useNostrPublish (which would re-queue and duplicate), re-signing only legacy unsigned entries. Rules: supersede — a new replaceable (0/3/10000+) or addressable (30000+) event drops the older queued one; TTL — ephemeral kinds (2259 spot status, 24899 spot chat) are pruned after 15 min (late "I'm here" is worse than none); cap 100; getPendingPosts returns pending and retryable failed (a single failure must not strand an action) and the count includes both; a module-level globalSyncing lock keeps ONE publisher even though OfflineIndicator (which owns the sync) is mounted twice; retries every 30s while offline→online. ⚠️ useNostrPublish now resolves (instead of throwing) when it queues — callers show their success toast, corrected by a global "Saved offline" toast + the badge.
    • Stash is readable offline + backed up (2026-09-29). The private stash (NIP-51 kind 30001, d=skatespots-stash, NIP-44 encrypted to self) was deny-listed from React Query persistence with NO local cache, so an offline/relay-outage cold start showed an EMPTY stash even though decryption needs no network. Now the encrypted CIPHERTEXT is cached in Dexie (offlineCache v2 stash table — cacheStashEvent/getCachedStashEvent), and both the read query and fetchLatestStash (the pre-write re-fetch) fall back to it — so the stash still loads at the park and an offline edit rebuilds from the real list instead of throwing. Only ciphertext is stored, never the decrypted list. Also added Export/Import on /stash (Backup & transfer card, desktop sidebar + mobile): export writes a JSON you control; import appends in ONE re-encrypt, skipping title+category+size duplicates (importItems). Encrypted backups (2026-10-03): src/lib/stashBackup.ts wraps the export in a version-2 JSON envelope — password mode (scrypt NIP-49 params 2^16/r8/p1 → AES-256-GCM, header bound as AAD so KDF/ cipher can't be downgraded) or nip44 mode (encrypted to your own key, no password). Plaintext v1 files still import. StashBackupExportDialog + StashBackupUnlockDialog (components/StashBackupDialogs.tsx); the picker prompts for a password only for password files. ⚠️ Password mode needs no signer (works with Amber); nip44 mode does. Verified round-trip / wrong password / tamper / KDF-downgrade rejection in Node. UX pass (same date): the filter bar gained a Sort (Newest / Value / Condition / A–Z) and search now includes size; a "Public stash" card lists which items are public (with the profile link for "see what others see"); and bulk actions — a header Select toggle puts rows/cards into multi-select (checkbox, click-to-toggle, per-item actions hidden) with a fixed action bar: Public / Private / For sale / Remove (bulkSetVisibility/bulkSetForSale/bulkRemove, each ONE re-encrypt) + a bulk-delete AlertDialog; showcase sync/unpublish runs per affected item. WTB is unified (2026-10-01): a public want (itemType: 'wtb', now defaulting to public in AddItemDialog) publishes a real NIP-99 wtb listing (useStashShowcaseSync.buildListingTags — the stash-want tag instead of stash-showcase, listing_type=wtb, price = what they'd PAY), so sellers find it in the Marketplace (which already filters listing_type=wtb), while the encrypted Wishlist tab keeps the private copy. The old item.itemType !== 'wtb' showcase guards were removed so the same sync/unpublish path covers wants.
    • Multi-device merge guard (2026-10-01): the stash is still ONE encrypted blob, but writes now MERGE instead of clobbering. Items gained an updatedAt stamp; deletes record {id, at} tombstones in a top-level deleted[]; mergeStashes() (exported from useStash.ts) unions items by id (newest stamp wins) and lets a tombstone win at/after the last edit. fetchLatestStash() merges the live remote with the list already held (React Query cache → hook closure) and toasts "Merged changes from another device" when the other side contributed. ⚠️ Writes now require a CONFIRMED relay read — fetchLatestStash() returns {stash, confirmed} and assertConfirmed() throws when no relay answered, so an offline stash write is REFUSED rather than queued: useNostrPublish would publish the pre-signed blob later as-is and could silently overwrite a newer edit made on another device. Reads still work offline from the cached ciphertext; StashPage surfaces the refusal via onStashError. Why not per-item events? N items = N nip44.decrypt() calls per load → N Amber prompts. Schema is in NIP.md → "Private Stash" → "Multi-device merge".
    • 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. Add-mint dialog (2026-10-04): MintPrivacyManager's add dialog now also lists the full MintRadar directory (searchable, useMintDirectory) below the hand-picked "Recommended" shortlist — previously it only showed the recommendations. ⚠️ vite.config.ts's server.proxy also covers /api/nip05 → https://skate-spots.org and /api/ai → https://ai.skate-spots.org (2026-09-28). Without them the dev server answers those production-only endpoints with index.html, so the NIP-05 availability check showed "Could not check right now" on localhost and dev AI silently fell back to Pollinations. (Prod is unaffected — the functions are same-origin there.) ⚠️ 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.

Angor crowdfunding — the SkateSpots project (2026-10-05)

SkateSpots is crowdfunded on Angor (Bitcoin + Nostr, no middleman). This is separate from the app code: a live on-chain project whose metadata is ordinary Nostr events signed by a project identity (derived from the founder's wallet, not the personal npub).

  • projectIdentifier (the kind-3030 d): angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z → Bitcoin address bc1qtunna00uqyx52rr0nvqa059z2gknhrmnqe7h8h.
  • project Nostr key (signs profile + metadata): a99eb1d4d546d3a3b40040cc93f77b9a7cd32fab1b693261e03ff1b7ce7400f = npub14x0tr4rd23kn5w6qqsxvj0mhhxnu6vh6kxmfxfs7q0l3kl88gq8sfgwk54. ⚠️ Different from the personal/platform key 20e7c953… (npub1yrnuj…, the git origin).
  • kind 3030 = funding definition (Fund type, Main, penalty 90d, threshold 100k sats, 3/6/9-monthly stage patterns). Its event id is committed on-chain via OP_RETURN → effectively immutable; do not edit.
  • Editable metadata = kind 0 profile + kind 30078 (angor:project, angor:faq, angor:members, angor:media). Live: displayName "Skate Spots", NIP-05 owner@skate-spots.org, lud16 skatespots@minibits.cash (single-sourced in src/lib/platform.ts), picture on blossom.angor.io.
  • In-app display: /supporters has a Supporters section (curated) fed by src/lib/backers.ts — { npub, amount (sats), note } or { name, logo, url }. Amounts come from the investment txs' Taproot stage outputs. Discovery helper: ~/.config/skatespots/angor/angor-backers.mjs (decrypts the founder's investment DMs → work/backers.json). ⚠️ Don't list a backer without consent.

Tooling — machine-local, deliberately NOT in the repo

~/.config/skatespots/angor/ → angor-metadata.mjs + blossom-upload.mjs (own nostr-tools/ws). The project nsec lives in ~/.config/skatespots/angor/key (mode 600, outside the repo); the tool refuses any key whose pubkey ≠ the project key. check · dump/show · publish [--yes] [--only …] · emit · claim-nip05 <name> · blossom-upload.mjs <file>. Get the nsec from the app: project → Private keys (the modal is titled "Project NOSTR Private Keys"; feet at m/44'/1237'/{upi}'/0/0).

⚠️ Gotchas that cost a whole session

  • Publish to ALL SIX relays the app uses: relay.angor.io, relay2.angor.io, relay.damus.io, nos.lol, nostr-01.yakihonne.com, nostr-02.yakihonne.com. RelayService.FetchProfileMetadataAsync does result = metadata on every reply — last response wins, not the newest event — so one relay still holding an older kind 0 overrides the update. (The project originally lived only on YakiHonne.)
  • The app's Edit Profile "Save Profile" republishes whatever the form shows. While the NIP-05/Lightning fields are blank, saving wipes them. Don't save until they load.
  • The app's Relays tab is cosmetic — EditProfileViewModel.SaveAsync never persists Relays and LoadAsync never loads them; adding relays there does nothing.
  • nostr-tools SimplePool + Node's built-in WebSocket is broken here: it reports a fake ✔ with no relay OK, and overflows the stack on a failed connect. The tool talks raw ws and waits for the relay's real OK — trust that output, not the old pool behaviour.
  • NIP-05: skate-spots@skate-spots.org is already bound to the personal key; a project profile needs a name that maps to the project key. claim-nip05 owner → owner@skate-spots.org (registry = netlify/functions/nip05.mjs, proof-by-signature).
  • Blossom (blossom.angor.io) returns a doubled-scheme URL (https://https://…); build <server>/<sha256>.<ext> yourself and verify the sha256 on download.

nak is installed (~/.local/bin/nak) — handy for nak req / nak event against these relays.

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).
  • Phone-frame preview (check the UI at device sizes in a desktop browser): http://localhost:8097/phone-frame.html — a static page that embeds the Vite dev server (:8080) in a phone-sized iframe (device presets, rotate, route box; ?app=/?route=/?device= params). Files live in ~/.config/skatespots/phone-preview/ and it's served by the systemd user unit skatespots-phone-frame.service (systemctl --user status skatespots-phone-frame). ⚠️ Machine-local, deliberately NOT in the repo (kept out of the build/deploy and the eslint HTML gate). It replaced an ad-hoc python3 -m http.server 8097 copy in /tmp that got wiped (2026-10-05) — don't recreate it in /tmp. Needs the dev server (skatespots-dev.service, :8080) up, else the frame shows "cannot be reached".
  • 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: npm run dev:fresh (clears node_modules/.vite then starts dev) — or, for the running systemd service, rm -rf node_modules/.vite && systemctl --user restart skatespots-dev — then hard-refresh the browser (Ctrl+Shift+R). npm i / vite build / vitest alone do not wipe the cache — it's the long-running server's re-optimize that races.
  • Prevention: after any command that runs npm i (npm test, npm run build, npm run dev, or a version bump), restart the dev server before browsing — the re-optimized cache writes fresh ?v= hashes, so an open tab stops 504-ing. Don't leave a dev server running across a build.
  • Resilience — self-heals now (2026-10-05): every route uses lazyWithRetry (src/lib/lazyWithRetry.ts). On a chunk-load failure it retries the import 3× with backoff, then does a cooldown-throttled full reload (>10 s since the last one), so repeated stale-hash failures each recover without a manual refresh. ⚠️ Do NOT reintroduce a once-per-session reload cap — the old version reloaded once per session, so the second failure dead-ended on "This page slammed" and read as an app bug. Only a genuinely persistent failure should reach 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 — start it directly with the notes, then --- before older versions. ⚠️ Do NOT begin the file with a vX.Y.Z line: Zapstore already shows the version, so the heading rendered as 1.5.12 v1.5.12 (duplicated) — removed 2026-10-05.

⚠️ 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 does not record the deployed SHA, so the pending change still deploys on a later tick; a failed deploy doesn't set the clock, so it retries immediately.
    • ⚠️ Every skip records its reason to ~/.local/state/skatespots-deploy/last-skip (epoch|reason|sha|detail) — previously a skip was silent. Read it back with scripts/ops-health.sh (below).
    • ⚠️ 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).
  • Ops health (scripts/ops-health.sh, 2026-10-01): answers "why isn't it live?" Two failures were previously invisible and cost a whole session: the ngit/Amber signer stalling mid-push (git push hangs at "signing event (git state) with remote signer") and the deploy timer skipping while origin/main had moved. The script reports repo/push sync, the recorded skip reason, the throttle window, and timer health; exit 1 when something needs attention.
    • Flags: --probe-push runs a bounded 45s real git push — signing only happens when there is something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or ngit account login --interactive). --notify = desktop notification (deduped by an actionable-problem signature). --quiet = silent when healthy. Test against a fake state dir with SKATESPOTS_STATE_DIR=….
    • ⚠️ git push --dry-run does NOT exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe.
    • Runs automatically every 15 min: skatespots-ops-health.timer → skatespots-ops-health.service (--quiet --notify). Install/refresh with scripts/install-ops-health-timer.sh; logs journalctl --user -u skatespots-ops-health.
  • 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.
    • Full publish sequence (run from the repo root; replace 1.5.13 and the signer):
      # 1) bump the version — package.json is the single source of truth
      npm version 1.5.13 --no-git-tag-version
      # 2) edit the TOP of release-notes.txt (start with the notes, NOT "vX.Y.Z")
      # 3) build the signed release APK (syncs the version into the APK)
      npm run android:build
      # 4) dry run — parses + signs, uploads nothing
      SIGN_WITH='bunker://<amber-connection>' zsp publish --offline zapstore.yaml
      # 5) publish for real
      SIGN_WITH='bunker://<amber-connection>' zsp publish zapstore.yaml
      
      ⚠️ zsp reads the version from the built APK (synced from package.json by android:build) — bump only package.json. Publish needs the signer awake (open Amber in the foreground). SIGN_WITH=browser fails here (no NIP-07 extension).

Relay archive (dataset backup)

  • Transfer content between keys (scripts/transfer-content.mjs, 2026-10-04): re-signs one key's addressable spots (30818) + shops (30017) under another key and publishes (optionally NIP-09-deleting the originals). Addressable events are author-signed, so a "transfer" needs both keys — TO_NSEC for the new events, FROM_NSEC only with --delete-originals. --dry-run (default) needs no keys. ⚠️ A claimed/derived event can't be moved without the author's key; the app's /my data can delete originals via the logged-in (Amber) signer.
  • 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.)

Internationalisation (EN / ES-Castellano) — decisions (2026-10-01)

Thai (ไทย) — third locale (2026-10-06)

  • Added th alongside en/es on the same framework; core plumbing unchanged. Translations live in src/i18n/th/ as fragments composed in index.ts (so parallel batches don't edit one giant file).
  • ✅ COMPLETE (2026-10-07): th is 100% — 7,889/7,889 keys. The partial-locale rule below still applies to any future keys.
  • Partial by design: a missing th key falls back to English. check:i18n is now multi-way — en/es stay key-for-key identical (unchanged), th (and ja) may be partial and are reported as coverage %; a th/ja key absent from en fails.
  • Font: @fontsource/kanit (Kanit) imported in main.tsx and added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have no Thai glyphs, so without it Thai silently falls back to a system font.
  • Detection: a Thai-device browser (th*) starts in th (detectLanguage).
  • The trick glossary (src/lib/glossary.ts) stays English.
  • Do-not-translate is broader than tricks for Thai: core skate vocabulary stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr, Lightning, Zap, Relay, sats) because Thai skaters use the English terms.

Ten more locales — 12 total, all 100% (2026-10-08)

  • Added pt, fr, de, it, nl, pl, ru, ko → 12 locales: en, es, th, ja, pt-BR, fr, de, it, nl, pl, ru, ko. All at 100% (8,450 keys), en/es in parity.
  • Locales are LAZY-LOADED. language.ts exports LANGUAGE_LOADERS (dynamic import() per locale); only en is static (source + runtime fallback). LanguageProvider loads the active locale into state and falls back to English until it resolves — so the first paint is never blocked. ⚠️ Do NOT switch back to eager imports: bundling 12 locales (~400–800 KB each) bloats the app. Each language is its own on-demand chunk.
  • Fonts: added Noto Sans KR (Korean) and Inter (Cyrillic/Russian) to the CSS + Tailwind stacks; JP/KR/TH each need their own font (Outfit/Inter lack CJK).
  • check-i18n.mjs now reports all 12; new locales are partial (coverage %) until complete. Adding a locale = register in language.ts (Language union + LANGUAGE_LOADERS), LanguageContext.detectLanguage, LanguageToggle, dateFnsI18n, and scaffold src/i18n/<lang>/{index,batch1..8}.ts.
  • Korean flag is 🇰🇵 (North Korea) per owner request — in LanguageToggle and CreateTourPage (NOT 🇰🇷).
  • ⚠️ One writer per file. Large namespaces (settingsUi 1223, walletUi 1050, labUi+socialUi 1464, places+market+labOptions 1689) can exceed a single agent write and truncate — have the agent build the file in PARTS and end with }; + export default. Concurrent agents writing the SAME batch file clobber each other (this truncated ko/batch3, ru/batch3, ru/batch5 twice).

Japanese (日本語) — fourth locale (2026-10-07)

  • Added ja alongside en/es/th on the same framework. Translations live in src/i18n/ja/ as fragments composed in index.ts.
  • ✅ COMPLETE (2026-10-07): ja is 100% — 7,889/7,889 keys, same as th. Both partial-locale rules below still hold for any future keys.
  • Partial by design (same as th): a missing ja key falls back to English. check-i18n.mjs reports ja coverage and fails if a ja key is absent from en.
  • Font: @fontsource/noto-sans-jp (Noto Sans JP) imported in main.tsx and added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have no Japanese glyphs, so without it Japanese falls back to a system font.
  • Detection: a Japanese-device browser (ja*) starts in ja (detectLanguage).
  • ⚠️ LanguageToggle inside a Radix Sheet/Dialog must use the inline prop. The dropdown is a Radix DropdownMenu that portals to <body>, and an open Sheet/Dialog sets pointer-events: none on <body> — so the portaled menu inherits it and its items become unclickable. LanguageToggle inline renders plain buttons (no portal) instead. The mobile nav sheet uses inline; the header keeps the dropdown (it is not inside a dialog). Same trap applies to any portaled popover/menu placed inside a dialog.

Hardcoded-string sweep (2026-10-07) — 7,889 → 8,337 keys

check:i18n only sees keys wired through t(), so hardcoded English that never called t() stayed English in EVERY locale (es/th/ja included). Those are now keyed. New namespaces live in src/i18n/legacy/ as { en, es } fragments composed in legacy/index.ts and spread into en/es (same pattern as batch3/–batch4/); the th/ja fragments mirror them:

  • deckCostUi — the whole /diy/deck-cost calculator (170 keys)
  • becomeRolesUi — /become/* role forms + CraftProfileForm (123 keys; added *Key fields to becomeRoles.tsx)
  • weatherUi — weather/AQI/skateability labels (60 keys; weather.ts gained *Key fields, consumers updated: SpotDetailPage, SpotMap, TripItineraryCard, the Sasquatch weather tool)
  • skaterLabelsUi (21) — skater stance/style/level + sponsor tiers in types.ts
  • notifyUi (28) — OS notification titles/bodies (OsNotificationBridge)
  • legacyMiscUi (~160) — scattered component/page literals, image alt/aria-label, and the generated export-document text (PDF/XLSX/PNG/Markdown/clipboard) ⚠️ Still English by design: the src/i18n/glossary.ts trick glossary, image alt/aria-label (mostly brands or shadcn internals), example placeholders (you@example.com, npub1…), and generated export-document bodies (PDF/XLSX). Re-scan with grep -rnP ">\s*[A-Z][A-Za-z][A-Za-z ,.!?&*()'-]{3,}<" src --include=*.tsx.
  • Katakana/English for skate vocabulary (spot→スポット, park→パーク, crew→クルー; Stash/The Lab/Nostr/Lightning/Zap/sats stay English). Trick names stay English.
  • Castellano term change (2026-10-07): crew is "peña" in es.ts, not "cuadrilla" — a case-preserving replace of 158 occurrences (Cuadrilla→Peña, cuadrilla→peña, CUADRILLA→PEÑA). ⚠️ Do not use perl -CSD on these files (it mojibakes UTF-8); use a UTF-8-safe edit/Node.
  • The app ships English + Spanish (Castellano, Spain). The framework already exists: src/contexts/language.ts (context/types/hooks) + LanguageContext.tsx (provider), src/i18n/en.ts + src/i18n/es.ts (one default-export object each), t() with dot-notation + {{param}} interpolation, English fallback and a console warning for a missing key. Toggle in the header (LanguageToggle) and Settings → Language; persisted in localStorage app-language.
  • First-visit auto-detect: detectLanguage() in LanguageContext.tsx starts Spanish-device visitors in es (Castellano), everyone else in English. A stored choice always wins.
  • Translation policy (owner decision 2026-10-01): Castellano; only skate TRICK names stay English (kickflip, ollie, nollie, heelflip, 50-50, …). Everything else — UI chrome, descriptions, buttons, errors — translates. Gear words DO translate (decks → tablas, wheels → ruedas); trucks/bearings are common as-is. Keep the do-not-translate list limited to tricks.
  • The Sasquatch replies in the app language: SasquatchGuide appends a languageDirective to the system prompt (both the first streaming pass and the tool-result synthesis pass) when language === 'es'.
  • Keep en.ts and es.ts key-for-key in sync — the fallback + warn makes drift silent otherwise.
  • User-generated content is never translated (spot / listing / DM / crew text is whatever the author wrote).
  • Safety net: scripts/check-i18n.mjs (wired into npm test as part of the chain) fails the build if any literal t('…') key is missing from en OR es, or if en/es drift. It also scans data tables for keys declared as key:/titleKey:/labelKey:/badgeKey: (e.g. src/lib/coachOptions.ts), because those are looked up dynamically via t(option.key) and a plain literal scan misses them — that gap shipped raw labUi.… keys to the UI once (fix 2026-10-04: 29 option keys were referenced but never translated). Still unchecked: template keys built by interpolation (t(\ns.${x}`)`) — keep those few in sync by hand.
  • Coverage (2026-10-04, batches 1–4 done): ~7,030 keys (EN=ES) and ~250 files wired. Namespaces: batch 1 — mapUi, spotUi, marketUi, labUi; batch 2 — nav (header/More/burger labels) + chromeUi (footer, toolbar widgets, spot-type labels), placesUi (events/shops/tours/food), socialUi (crews/DMs/notifications/collectives), settingsUi (settings/auth/signup/ onboarding/profile/legal), walletUi (wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile page); batch 3 — see below.
  • Batch 3 (2026-10-02) — done. Its namespaces live in fragment files under src/i18n/batch3/, spread into en/es via batch3/index.ts (export default { ...en, ...batch3En }), so parallel translation passes never edit the big en.ts/es.ts literals — add keys to the fragment, not the literal.
    • routeUi.* — RouteDialog (follow mode, transit time picker + mode filters, itinerary legs), StopDepartures, TripItineraryCard, and the routing helpers (skateTime, distanceReadout, maneuverDescriptor → MODE_LABEL_KEYS/MANEUVER_LABEL_KEYS static maps, routeCache.formatAge).
    • sasquatchUi.* — SasquatchGuide, SasquatchProDialog, SasquatchActionCard, SasquatchAiStatusCard, plus sasquatchPro.ts PRO_TIERS.labelKey/badgeKey and useSasquatchActions.summarizeDraft(d, t). LLM system prompts and chat bodies stay English (the model is language-directed separately).
    • collectionUi.* / bootUi.* — AddToCollectionDialog placeholders, SplashScreen status lines.
    • labelsUi.* (221 keys) — the shared data-module labels: spot categories/ features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. src/lib/labelKeys.ts holds the shared *_KEYS value→key maps (single source; the five duplicated EVENT_TYPE_KEYS copies now import it). statusTimeAgo() and formatPrice(price, type, t?) are locale-aware now.
    • Dates & relative times are localized (2026-10-02): src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow/…Strict/formatDistance/format defaulting to the active locale, plus dateLocaleCode() for native toLocale{Date,Time}String. LanguageProvider calls setDateLocale(language) during render so the first paint is in-language. Import from @/lib/dateFnsI18n, not date-fns. Number .toLocaleString() calls are deliberately left on the runtime default.
  • Batch 4 (2026-10-04) — done. Fragment layer src/i18n/batch4/ (spread as batch4En/batch4Es alongside batch 3).
    • labOptions.* (430 keys) — every Lab *_LABELS/*_DESCRIPTIONS map in src/lib/types.ts (coach/filmer/editor/photo/builder/artist/graffiti/tattoo/ trainer/musician/judge/organizer/barber) + TOUR_SKILL_LEVELS; 51 shared key maps appended to src/lib/labelKeys.ts. Consumers: becomeRoles.tsx, CraftProfileForm (SkillSection gained a keyMap), LabPage, CoachDetailPage, the five bespoke /become/* pages, TourCard/ TourDetailPage/CreateTourPage/EventsPage. Trick names and tool/brand nouns intentionally stay English.
    • sasquatchChat.* (83 keys) — the free-tier STATIC_FAQ (English matcher → sasquatchChat.faq.* key) and local-result/fallback chat bodies. Live AI is already language-directed; prompts stay English.
    • feesUi.* (57 keys) — usePurchasePro receipt prose/errors and PlatformFeePayment fee/duration tiers (LabProUpgrade/FeaturedEventUpgrade were already labUi/placesUi).
    • ✅ Not translated, deliberately: LLM system/synthesis prompts and tool-result instruction strings (model-facing), feed-supplied data (line names, headsigns, roads, stops, weather condition labels, operator names), brand/proper nouns, and the separate src/lib/glossary.ts trick glossary. TagCategory names/descriptions and MUSICIAN_ACT_LABELS have keys but no rendering consumer. Number .toLocaleString() stays on the runtime default. Grep a module before assuming it is untranslated.
  • ⚠️ A batch-2 agent dropped the socialUi. namespace prefix in ~36 files — the check:i18n guard caught all 738 usages. Always run node scripts/check-i18n.mjs (it's in npm test) after wiring a file.
  • ⚠️ When wiring a file, keep en.ts/es.ts key-for-key in sync and run node scripts/check-i18n.mjs before committing.

Security & correctness audit fixes (2026-10-04)

A five-part audit (security · bugs/logic · UI/UX · Nostr interop · flows) was run; this is what was FIXED (each behind npm test + smoke) and what remains.

Fixed — money & auth. Marketplace payments verify the invoice amount (usePayPaymentPointer → assertInvoiceAmount), so a seller can't overcharge via a raw BOLT11 or an inflated LNURL invoice. NIP-17 DMs call verifyEvent(seal) in all four read paths (sender impersonation). push-service enforces endpoint ownership on subscribe/unsubscribe and validates the endpoint (isSafePushEndpoint, SSRF) — ⚠️ requires npm run deploy:push to take effect. The Amber callback no longer logs in from a bare ?event=<pubkey> (must match a pending get_public_key) and no longer logs the pending-op payload. usePlatformFee records are per-pubkey (was device-global).

Fixed — trust/data integrity. useAdminModeration fetches the cleared label (Admin Undo now propagates cross-device). Admin takedowns publish the NIP-32 hidden label instead of an ignored kind-5. The /stats "partial outage" banner works (queryCanonical gained an onError hook). useShops has its own cache namespace (no longer collides with useSkateShops). Listing NIP-05 is verified (verifyNip05), not self-asserted. NIP-22 spot comments/transfers use uppercase root A/K/P and readers accept legacy #a. Lab profile edits preserve foreign a spot refs; collections preserve foreign NIP-51 tags; LAB_KINDS = all 13. Skip-to-content is a button that focuses <main> (HashRouter-safe). CustomizeNavSheet is translated; useLocalStorage re-reads on key change and keeps stateRef in sync cross-tab; the backup flag is per-account (lib/backup.ts). Listing detail / Events render ErrorState on outage.

Deferred (documented, NOT fixed).

  • H1 — PARTIAL: claim-from now stores the original coordinate (30818:pubkey:d, not an ambiguous bare pubkey), SkateSpot parses claimedFrom/claimReason, and a claimed listing shows a "Claimed listing" notice. Reader-side supersede is still open — gating it safely (only an admin-approved private claim, or an aged original) is a design item; a wrong rule would hide real spots.
  • C5b — FIXED: src/lib/featuredVerification.ts verifies a provider-authored NIP-57 receipt (cached provider lookup) and the tour/event/Lab list hooks rank only receipt-backed featured items; falls back to the self-asserted tag when the provider is unreachable (availability over strictness).
  • H12 — FIXED: a preimage/deposit proof counts at most once (dedupe), so a real payment can't be reused to inflate paymentVerified.
  • H13 — FIXED: the offer seller is always the listing author (derived from the event pubkey), never a self-declared role tag.
  • H14 — FIXED: after an NWC pay() timeout, useNWC polls lookupInvoice for settlement and, if still unknown, throws a coded error (PAYMENT_STATUS_UNKNOWN). The callers that used to fall through to another rail (useZaps, usePurchasePro, PlatformFeePayment) now stop and tell the user to check their wallet — no automatic second payment.
  • Medium/Low mostly fixed in a follow-up "tier 3" commit: tour sponsor URL sanitized (XSS); stats persisted-cache reads null-guarded; Overpass no longer assumes .elements; user-listings query key memoized; Pro grant requires the l label; verify-card RSVP maps accepted/tentative; expired listings can't be bought by URL; batch spot photo/status hooks take the relay suffix + querySkateContent; offline publishing rows requeued; replaceable-event dedupe by author+kind; route cache key includes origin; MarketplacePage uses <Link>; EventCountdown reacts to language.
  • Still open (lower priority): manual Pro "paid" isn't receipt-verified; the background poll tokens are cleaned up server-side (register replaces per pubkey) but disable() doesn't unregister; H1 reader-side spot supersede (partial — schema + notice done); a few i18n leftovers (some StashPage literals) and gray-500/600 contrast.

Event comms — Concord + Cordn (idea only, NOT started — 2026-10-03)

Parked for later evaluation; no decision, no code. The idea: a host tool for running each event's communications, split by event visibility (public vs private) — not a "staff room". Owner = the event's main host; per event, plus a host console that lists/manages them. We likely run no infrastructure.

Recommended default → Concord (concordprotocol.org, MIT; Discord-shaped E2EE over Nostr; relays only, no server):

  • CORD-01 private streams · CORD-02 communities (key possession = membership; owner-rooted roster; community_id; community_root/control_root; epochs; Control/Chat/Guestbook planes) · CORD-03 public and private channels (each its own key) · CORD-04 roles · CORD-05 invites (revocable links + kind-3313 direct invites) · CORD-06 rekeys/refoundings (removal = rotate) · CORD-07 A/V · CORD-08 disappearing messages.
  • ⚠️ Concord "public" is still E2EE (open membership, not world-readable) — world-readable chatter is plain Nostr (kind 1/1111 on the event). Relays must NOT enforce NIP-59's p guard (members subscribe by stream author).
  • Kinds: 1059/20013/20014/21059 envelopes; 9/1111/7/5/3302 chat; 3308 control; 3303 rekey; 3306/3309/3312 guestbook; 33301 invite bundle; 13303 invite list; 33302 community list; 3313 direct invite.
  • ⚠️ No official JS SDK (vector_sdk is Rust). TS references: Armada (Capacitor/TS) and Accordion (browser TS). Writing one is a real project (frozen HKDF derivations, the Control-plane fold, epochs, byte-identical encoding; interop failures are silent empty rooms).

Optional upgrade (private events only) → Cordn/MLS (github.com/Cordn-msg/cordn; ts-mls coordinator + ContextVM server; per-group cursors; KeyPackages; Docker ghcr.io/cordn-msg/cordn). Gives **forward secrecy

  • post-compromise security + true member removal**. ⚠️ Requires a coordinator — if we don't run one, the host supplies one (public default on relay.contextvm.org, or self-host). Cordn has no A/V. We already ship a Coordinator settings card + src/lib/cordn/* + a crew coordinator tag, but the app currently uses a Cordn URL as a relay and does NOT do coordinator RPC — real MLS + RPC is new work.

Open questions on revisit: (1) public event = open-join E2EE (Concord) vs world-readable (plain Nostr) vs both; (2) one host community with a channel per event vs a community per event; (3) is a Concord private channel (zero infra) enough for private events, or is MLS worth requiring a coordinator?

Identity & conflict — parked until there are users (2026-10-07)

Owner decision: build nothing here yet. Zero users — revisit organically rather than guessing. Thesis on the table (not acted on): internet toxicity is downstream of centralized identity + centralized conflict resolution; the fix is identity at a lower level + plural, viewer-applied filtering. A full idea list was discussed this session but is deliberately NOT started.

Keep the door open (cheap): admin keys and relay sets must stay data you can swap, not constants. Hardcoded ADMIN_PUBKEYS is fine now — converting it later is a small refactor, not a trap.

Trigger 1 — first time you want to hide/remove something, or an admin key is compromised: add labeler choice (users subscribe to signed moderator lists; ship a default that is forkable, not baked in) + show who decided (signed verdicts + author right-of-reply). Today: NIP-32 hidden/verified/cleared from a hardcoded admin set = one key is the single point of capture.

Trigger 2 — first shop or coach (stranger-trust, not just friends): add social re-vouch (a new key claims succession of an old key, endorsed by N ≥ known contacts, optional cooling period — no crypto shares) + issuer-signed credentials (a federation/body key signs certs; users choose which issuers to trust). Re-vouching rebuilds trust only — it can't move old addressable events, the NIP-05 handle, zaps, or account age, and it can't help a shop/coach/stranger.

Do NOT sell Stash backup as key recovery. src/lib/stashBackup.ts password mode survives key loss but restores only the gear list; nip44 mode + multi-device sync need the key. The key is the identity (npub, spots, crew, passes, handle, rep). PoW is anti-spam friction, not identity.

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:

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

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

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

{
  "kind": 30402,
  "content": "",
  "tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]]
}

❌ Bad - structured data in content:

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

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:

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:

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.

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:

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

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

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

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

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

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.

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.

// ❌ Wrong: naddr is not decoded
const events = await nostr.query(
  [{ ids: [naddr] }],
  { signal }
);

Corrected example:

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

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.

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.

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

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:

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:

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

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

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:

    import '@fontsource-variable/<font-name>';
    
  3. Update Tailwind configuration in tailwind.config.ts:

    export default {
      theme: {
        extend: {
          fontFamily: {
            sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
          },
        },
      },
    }
    
  • 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:

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.