308 KiB
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-domwith 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-domHashRouter — real routes live in the URL hash (/#/settings,/#/marketplace). A bare/settingsrenders the homepage (this silently broke the boot smoke test once).src/AppRouter.tsx. - Custom protocol:
NIP.mdis the source of truth for every custom/adopted kind; the constants live insrc/lib/types.ts. Highlights:30818skate spot ·30017shop ·31923/31925events + RSVP ·30823toursCreateEventPagecan link a shop as the venue too (not just a spot):['a','30017:<pk>:<d>']+['venue-name', <name>](2026-10-04).
30402NIP-99 listing = marketplace item, and the Assertion Event for trades30403marketplace offer (+ seller response) — seeNIP.md31871trade attestation (sale/purchase) ·31872trade confirmation request ·31873trusted-trader recommendation →src/hooks/useTradeAttestations.ts/useTraderRecommendations.ts; reputation UI inTradeReputationCard/TradeRecommendationsCard35000–35014role profiles (skater/filmer/coach/…/event-organizer/barber) ·35006crews ·30003spot collections- Lab offer flows (
/become/*): the 7 craft roles shareCraftProfileForm; coach/filmer/editor/photographer/builder are bespoke but follow the same conventions. Forms prefill from the existing profile to edit, and preserve theggeohash + paidfeaturedtag on edit. Bare domains (example.com) are coerced tohttps://bynormalizeUrlInput()(src/lib/sanitizeUrl.ts) before storage, so links aren't silently hidden bysanitizeUrl()at render (the extra-fieldurltype used to be dropped outright).MIN_TITLE_LENis 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 satisfyisValidEmail(), and website/portfolio must resolve throughsanitizeUrl(normalizeUrlInput(...)). Invalid input shows an inline hint and blocks publish (socialis 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 likeexperience/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-formcert_note— never shown as verified),language(free-form),availability,booking(URL),photo(repeatable gallery, ≤12) andvideo(YouTube/Instagram reel). Option sets + i18n keys live insrc/lib/coachOptions.ts; the form renders a reusableOptionChipsrow; the gallery reusesPhotoDropzone+ImageLightbox; the reel reusesparseCrewVideo(src/lib/videoEmbed.ts).parseSkateCoachexposesformats/levels/ageGroups/credentials/languages/availability/booking/photos/videoonSkateCoach. Documented in NIP.md → "Coach (kind 35001)"./labcoaching filters (2026-10-04): the filter drawer gains a per-coaching facet section (formats/levels/ages/credentials/availability/ languages) built fromCOACH_FACETS— only values actually present in the loaded profiles are offered; selections show as removable chips in the toolbar and count towardactiveFilterCount. 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 legacyratestill renders and acts as an optional note. The form is grouped with aFormSectiondivider, the identity block is a neutral avatar card (it was a red error-looking box), and chips usemin-h-0— the global mobilebutton { 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 optionalpayment_note, apay_what_you_canflag, and an optionalbooking_fee(+booking_fee_currency). A booking fee is paid directly to the coach's kind-0lud16via the existingPayWithSats(NWC→WebLN→Cashu) — mounted only while open — and is not escrowed/refundable through us (copy says so). In-app payment is offered only forsatswhen 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 namedpayment— 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) andcomponents/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) renderLabExtrasFieldsand append its managed tags on save; the sharedCoachDetailPagerendersLabExtrasSectionfor every craft (coach-specific formats/levels/ages/ credentials stay in the coach block). Per-craft context stays inskillSections+extraFields, and per-craftrateUnits(billing units) are set inbecomeRoles.tsx./labfilters readparseLabExtrasfor all crafts. ⚠️ A new craft-specific freeformextraFieldis invisible unless the detail page has a matching section — declare it insrc/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.tsxis 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 intoCraftProfileFormand all 4 bespoke pages + coach via a Preview button next to Submit.parseLabExtrasnow 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 (ShareProfileDialogwith apathoverride), and/labshows 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/useLabDraftautosaves 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);LabDraftBannergives Resume / Discard; clears on publish. Progress:LabProfileProgressis 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.tsbuilds 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 withdisplay: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 toCraftProfileForm(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, emitsFile[]to its caller (which owns uploads/toasts). Used byCreateSpotPage,EditSpotPageandAddPhotoToSpot, replacing the old one-file-at-a-time inputs. 24899spot chat ·2259spot status ·32671Sasquatch Pro ·7103event updates- Crew handles (2026-10-03): a crew (kind
35006) may carry up to 5nip05tags, each verified against the founder key (SkateCrew.nip05s;nip05= first, back-compat).CrewFormadds/removes handles with a live verified/mismatch status;CrewNip05Badgerenders them all. Unlike a kind-0 profile (a singlenip05string), a crew is our own tag convention, so it isn't limited to one. Normalise → dedupe → regex-validate → cap 5 inparseSkateCrew. - Shop handles (2026-10-03): same idea on shops (kind
30017) — repeatablenip05tags (up to 5) verified against the owner key (SkateShop.nip05s), added/removed inCreateShopPage/EditShopPage('nip05'is inEditShopPage'sMANAGED_TAGSso republish doesn't duplicate them); shown viaNip05Badgeson 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
nip05field 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; seelib/profileHandles.ts+hooks/useProfileHandles). Primary stays in kind-0nip05;EditProfileFormedits extras;NIP19Pageshows all viaNip05Badges. Shared editor:components/Nip05HandlesEditor.tsx(used by profile/crew/shop). - Shared NIP-05 UI (2026-10-03):
components/Nip05Badges.tsxis the generic verify-and-render chip row (any owner pubkey);useCrewNip05is the generic verifier (name kept for back-compat). - Rider Cards (2026-10-02) — now REAL + verifiable.
32271Rider Card (addressable,d=rider-card) — the skater signs their own card customization (theme/style/skills/sponsors/rides-for/show-flags;v:1marks show flags explicit).35679Park Access Card (venue-bound via an optional["a","30818:<parkPubkey>:<d>"]tag).1293witnessed skill badge. Routes:/card(editor) ·/#/card/<npub>(public, read-only) ·/#/verify/<naddr>(signature check + venue binding). Helpers insrc/lib/riderCard.ts(parseRiderCard/riderCardTags/cardVerifyUrl), hooksuseRiderCard/useRiderBadges, visuals insrc/components/RiderCard.tsx(ONE React renderer; the canvas export inRiderCardPageis 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 noatag 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 profilelocation/home_spot), and Lab role chips — all derived LIVE (kinds 35006 / 35000 / 35001–35014), never claimed on the card, toggled byshowflags (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/cardeditor 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 kind35006, so its QR verifies that event (the founder's key is the signature)./verify/:naddrrenders kind35006too. Card fields: name/logo/home turf/founded/members/spots/vibes + Open call whenlookingFor. Event pass/wristband (2026-10-02): a fourth/cardtype — 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/:naddrrenders kind31923too. Shows title/date/venue/cover + who it admits + going count. Claims tab (2026-10-02):/reviewhas 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 +CardDatadropped the self-claimed skills row and gainedlevel. Customization depth (2026-10-02): card config gainedaccent(custom hex overriding the theme via--ss-accent+ canvas palette),logo(personal crest, uploaded), andback(printable reverse side — ReactRiderCardBack- canvas
drawCardBack; Print renders both sides). Park passes enforcevalid-thru(passExpiry()→ EXPIRED badge on card + verify; the input is a date picker). Card schema →v:3. Public/#/card/:npubalso 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 viauseCardProfile(tolerant JSON overquerySkateContent, so it can't drop a real profile the wayuseAuthorcould);DEFAULT_RIDER_CARD.styleis 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 (atag) and is only issuable by that venue's owner key;/verifyshows 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/verifychecks 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-markerptags (useCrewRole(crew).isElevated); platform moderators = hardcodedADMIN_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.tswrites the card/pass verify URL — Web NFC (NDEFReader) on Chrome/Android, or the local Android pluginpackages/skatespots-nfc(SkateSpotsNfc, registered by name via@capacitor/core) in the APK, because Web NFC isn't exposed in the Capacitor WebView.NfcWriteButtonrenders nothing where unsupported (iOS/desktop), wired to the issuer lists + the holder's "My passes". ⚠️ UNVERIFIED (needscap 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:buildwiresskatespots-nfcin and merges the NFC permission (the plugin needsandroidx.appcompaton its classpath — Capacitor'sactivityis anAppCompatActivity). 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 theshowflags are only crews/local/roles andspotsAdded/zapsReceived/eventsCreated/memberYearare gone from the card types); holographic is persisted (holotag); the public card derives style/level from the Skater profile (wasconfig.style= ''); the share link is/#/card/<npub>(was a path HashRouter ignored); issued passes dedupe byd(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; legacystyle/skillsare gone from the config/CardData; card links are clean (/verify/<naddr>,/card/<npub>) via amain.tsxstartup 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 (accessTypeprivate/locals) — it shows the venue's active/revoked pass counts and links into the studio with the venue preselected (/card?venue=<kind>:<pubkey>:<d>;RiderCardPagereads the param and opens the park editor on that venue). Renders nothing for non-owners, so no relay query fires.
- canvas
- Marketplace:
GearSwapPage(grid) +MarketplaceItemCardand a listing detail page at/listing/:pubkey/:identifier. Trade/escrow actions live in the sharedListingTradeActions(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 withverifiedCounterparties(distinct people mutually confirmed with) instead of the rawverifiedcount, 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 deckrating(seeratingtag). No payments are implemented — "Buy" is intentionally absent; buyers contact the seller and settle off-app. A/tradespage ("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 (kind30403,useMarketplaceOffers+ theListingOffersPanel/OfferDialog) are a counter-offer negotiation thread (2026-09-29): every message is its own event with a uniqued(append-only) carryingrole(offer= buyer /response= seller),e= the message it answers, andstatus; either side can accept / decline / withdraw / counter andListingOffersPanelshows the volley + whose turn it is. Legacy fixed-slotds are still parsed (role inferred when theroletag is absent) — see NIP.md → "Marketplace Offers". Trade/offer steps also feeduseTradeNotifications→ the bell's All tab (TradeNotificationCard, counted in the badge againstseenAt) and OS notifications (newtradescategory innotificationPrefs, wired inOsNotificationBridge). 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) andtrade-confirm(they confirmed —usePendingTradeConfirmations, now carryingcreatedAt/eventIdso it can be notified; confirming back is what makes the trade mutual and builds reputation). Barter is supported by design: trade attestations carrymethod(cash|lightning|trade|other—MarkSoldDialogpicks 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 anitemtag (["item","my 8.25 deck"]) instead of or alongsideamount—OfferDialogand the counter UI expose it, and the bell/OS text reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already supportprice_type=trade("Looking to swap");LISTING_TYPESitself is stillselling|wtb.- In-app payment on an accepted offer (2026-10-01): the seller may attach a
["payment", "<value>", "<kind>"]pointer to theaccepted30403message (kind=lninvoice|lnaddress|creq|clink; optional["payment_hash","<hex>"]).ListingOffersPanel.respond('accepted')attaches the seller's own profilelud16when 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, whichOsNotificationBridgeforbids). Buyer sees a Pay with sats button →PayWithSatsdialog →usePayPaymentPointerpays via NWC → WebLN → Cashu melt (mirrorsuseZaps), falling back to the seller'slud16if the accept carried no pointer. Nothing is held or signed by us — recording the trade stays the existing31871flow (prooftag documented in NIP.md).creq/clinkare 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=fixedlisting shows a direct Buy with sats button inListingTradeActions(shared by card + detail). The price is resolved to sats — SATS directly, USD viauseUsdToSats(live rate); other currencies fall back to the offer/contact flow — and paid through the samePayWithSatssheet (NWC → WebLN → Cashu melt, falling back to the seller'slud16). ⚠️ 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 onGearSwapPage. ⚠️ The app never collects an address: ashipsdeal exchanges it privately in the trade chat (NIP-17), andListingTradeActionsnudges 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 onCreateListingPagewhenfulfilment != pickup).GearSwapItemCard/MarketplaceItemCardshow distance from the viewer's last known location (cache only viagetLastLocation— never triggers a GPS prompt) andGearSwapPagegains a Nearest (local) sort (itemDistanceKminlib/sortOptions.ts). Guided handoff —HandoffHelper(rendered inListingTradeActions, 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.shipsdeals now get a safety nudge on all platforms, not only when escrow is available. - Meet-at-a-spot + shipment status (2026-10-01):
HandoffHelperalso 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_KINDSinpush-service), andd = ship:<listing-d>:<author>so a status replaces only its author's. ⚠️ It's a claim, not proof —receiveddoes 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,
TradeReputationCardflagsverified >= 5 && verifiedCounterparties <= 2— "most confirmations come from very few accounts". - Anti-sybil depth (2026-10-01):
src/hooks/useTradeIntegrity.tsadds two levers toTradeReputationCard. Activity weighting —useBatchActivitycounts each counterparty's published spots+listings and multiplies their contribution bymin(1, activity/3), so a ring of empty accounts contributes ~0 even when cross-confirmed. Ring analysis —useTradeRingsdoes a 2-hop query of the partners' own attestations and measures closedness (share of possible partner↔partner trades);≥3partners with≥0.5closedness 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.useNostrPublishacceptspow?: numberand mines the nonce on the UNSIGNED event before signing (the id is signature-independent, so the signer is still prompted once).CreateListingPagesends 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).parseMarketplaceItemexposespowDifficultyandMarketplaceItemCardshows 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(inlib/bolt11.ts, verified against the BOLT11 spec test vector).usePayPaymentPointernow captures the preimage from NWC/WebLN/melt and returns aPaymentReceipt;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](anddepositProof→["proof","deposit",…]).useTradeReputationverifies it (sha256(preimage) === payment_hash) and counts it inpaymentVerified. ⚠️ 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; thedepositproof is the lever that gives barter a real cost. Never promote a barter or a plainlninvoiceclaim to verified. The barter deposit is now a UI affordance:ListingTradeActionsshows a "Good-faith deposit" input forprice_type=tradelistings; paying it stores a deposit-slot receipt (saveReceipt(coord, r, 'deposit')) that the buyer's confirm attaches asdepositProof(method=trade).
- In-app payment on an accepted offer (2026-10-01): the seller may attach a
- Cashu escrow (marketplace): time-locked P2PK (lock to seller + buyer
refund key + locktime) — see
ESCROW.mdandsrc/lib/cashuEscrow.ts(unit-tested). Protocol:cashu_tokenon the31872; each user advertises acashu_escrowpubkey 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 inESCROW.md(tiny amounts) before trusting it with real money.
- redeem (DM request banner),
- 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 parsesbitcoin:BIP21 — extractinglightning=(→lightning_invoice, payable today via NWC/Cashu melt) andcreq=— plus standalone NUT-18creq1…/creqA…as a newcashu_requesttype. This is the Numo POS interop: a Numo terminal's default "Unified" QR isbitcoin:?creq=…&lightning=…, so it pays through the existing Lightning path with nocreqimplementation.cashu_requestis recognized but NOT payable yet —CashuWalletCardshows an honest toast; paying NUT-18 is a later step. ⚠️ Acreqis amount-bound/expiring, so a Lightning address (lud16) is the robust default for any future listing payment pointer (BIP21 parsing deliberately avoidsURLSearchParamsso a rawcreqA…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 inuseDirectMessages), plus two app-local categories —localRelays(never published; added to reads+writes) andblockedRelays(excluded everywhere).resolveRelays()insrc/lib/relays.tsapplies them;NostrProvideruses 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.
- All relay consumers now go through
- Group DMs: NIP-17 (
useDirectMessages,GroupConversationPage).- ⚠️ Zod strips undeclared keys — it cost us the DM relay list (fixed
2026-09-27).
AppProvidervalidates the persisted config withAppConfigSchema.partial().parse(...), andRelayMetadataSchemaonly declaredrelays+updatedAt. Zod's default behaviour is to drop unknown keys, sodmRelays,localRelaysandblockedRelayswere wiped on every load. The DM inbox relays (kind 10050) are usually NOT in a user's NIP-65 list, so withdmRelaysgone 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 newRelayMetadata/AppConfigfield 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 relaynostr.mom, 0–1 on their NIP-65 relays). Fix:useResolvedDmRelays()inuseDirectMessages.tsdiscovers 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 byuseConversation/useConversations/useGroupConversations/useGroupConversation.useConversationsnow returnsConversationsResult({ conversations, undecryptable, wraps }) — consumers areDirectMessagesPageandContactsPage— 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 onOK:false, so per-relay failures are real; the composer only clears on success. - Amber "flashing" on navigation (fixed 2026-10-12). For an interactive
signer every
nip44.decryptopens Amber, and the read hooks decrypt 2 per gift wrap (seal + rumor) — up to ~400 calls per query, andDirectMessagesPagemounts two such hooks at once (useConversations+useGroupConversations).amberSafeQueryOptions(useDirectMessages.ts) only disabled polling/focus-refetch and set a 5-min staleTime, so navigating back re-decrypted. It now also sets, for interactive signers:staleTime: Infinity,gcTime: Infinity,refetchOnMount: false,refetchOnReconnect: false— decrypt once per session, mirroringuseStash/useSyncedAppData. Consequence: Amber users don't get live DM updates, soDirectMessagesPagegained a manual Refresh button (socialUi.dms.refresh; en/es). Same guard added tousePrivateContactsanduseCordnChat. AlsouseSyncPendingPosts(useOfflineCache.ts) signed queued legacy/unsigned posts in a 30 s loop with no interactive gate — it now skips unsigned posts for interactive signers (pre-signed ones still publish). ⚠️ If you add a new read hook that decrypts, gate it the same way or Amber will storm on every navigation. ⚠️ Never fire concurrent signer intents with an interactive signer (fixed 2026-10-13).useSendDirectMessagecreated the per-recipient gift wraps withPromise.all, andsealGroupMessage(lib/cordn/crypto.ts) encrypted to the whole roster withPromise.all. Amber is a single foreground activity: severalnostrsigner:intents at once means only one is answered, and the others sit until the 60 sTIMEOUT_MSinandroid-templates/AmberIntentPlugin.javarejects with the misleading "Amber didn't respond. Make sure Amber is installed". Both now send sequentially for an interactive signer (parallel kept for silent ones); the NIP-17 crew Chat therefore costs 2 approvals per member (encrypt + sign). Steering Amber users to the MLS Private chat (≈1 approval per action) is the real cure. Crew-chat audit + fixes (2026-10-13, seeCREW_CHAT_AUDIT.md): ① a sent crew message didn't appear —useSendDirectMessagenever invalidated['group-messages', gid, …](the crew thread key); it now optimistically inserts the sender's own plaintext (no refetch ⇒ no Amber pops). ② All four DM readers shared no work — each decrypted every wrap (2 ops) under its own query key, so every view re-decrypted the whole inbox; now a module-leveldecryptGiftWrapcache (keyedviewerPk:wrapId, coalesced, bounded 4000, successes only so a transient failure retries) is used byuseConversations/useConversation/useGroupConversations/useGroupConversation. ③ threads dedup by rumor id; ④CrewChatguards double-send with a ref; ⑤ the Cordn path optimistically inserts too and invalidates only for silent signers. Standardised the Chat tab on NIP-17 (2026-10-13): a crew with acoordinatortag no longer silently reroutes the Chat tab — coordinator routing lives only in the Private chat (MLS).CrewChatno longer takes acrewprop or the Cordn branch; the tab's unread dot (Cordn-only metadata peek) was dropped.useCordnChat.ts,useCrewChatUnread.tsandlib/cordn/{client,crypto}.tsare kept but unwired (marked in their headers) — re-wiring is a deliberate decision, not a one-liner, because it split chat history. Group reads widened to 500 wraps (1:1 stays 200). Amber audit (2026-10-13): result COMPRESSION. The web/PWA path always sentcompressionType=none; the native intents sent neithercompressionTypenorreturnType, and the JS gzip decoder was never called. NIP-55 lets Amber gzip a large result (Signer1…base64), so a longnip44_decrypt(a long DM) could arrive compressed and be parsed as garbage — a "sometimes" failure. Fixed both ends:AmberIntentPluginnow sendscompressionType=noneon every intent (template →android/at build), andcapacitorAmberSignergunzips anySigner1…result anyway. Also: the timeout message no longer wrongly blames "Amber is not installed". Verified sound: MainActivity registers theActivityResultLauncherbeforesuper.onCreateand routes results toAmberIntentPlugin.handleResult(id-matched, single-pending fallback);reconstructSignedEventverifies the signature; rejections normalize;isInteractiveSignergates background decrypts; the only multi-signer loops are sequential now. ⚠️ Web/PWA Amber is inherently degraded: sign_event relaunches via a page reload and encrypt/decrypt results can't resume the original promise after the redirect, so web Amber crypto doesn't complete (use an extension/bunker on web). Native is unaffected. Persisted DM decrypt cache (2026-10-13):src/lib/dmDecryptCache.ts(DexieSkateSpotsDmCacheDB, keyedviewerPk:wrapId, bounded 4000/account, pruned oldest-first on hydrate) is used bydecryptGiftWrap— so a message is decrypted once, ever, killing the first-load-per-launch Amber storm. ⚠️ It stores plaintext DMs at rest in IndexedDB (device-local, never published) — the trade-off for not re-prompting; it is cleared on logout (AccountSwitcher) so another account on the device can't read it.clearDmDecryptCache()is exported for a future "clear cache" action. - 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 Functionnetlify/functions/ai.mjsat/api/ai— which runs against Netlify's AI Gateway. No key is managed or shipped: Netlify injectsOPENROUTER_API_KEY+OPENROUTER_BASE_URLinto 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) — keepALLOWED_MODELSin the function in sync withPROXY_MODELSinsasquatchBrain.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/airequiresx-sasquatch-auth(a NIP-98 kind-27235 event) plusx-sasquatch-pro(the Pro zap receipt, verified offline incl. paid-amount→tier); platform owners bypass. Disable withnetlify 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 BOTHSasquatchGuideand the crew agent (useCrewAgentChat). Callers without credentials 401 and fall back to keyless Pollinations. The pay-to address is single-sourced insrc/lib/platform.ts. The upstream is provider-agnostic:SASQUATCH_AI_BASE+SASQUATCH_AI_KEYoverride 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_MODELSmust matchPROXY_MODELS). ⚠️ The aggregatorapi.routstr.comis DOWN (404) — use a node. Nodes announce on Nostr as kind 38421 with the endpoint in autag. Live + working (2026-09-27):https://routstr.githappens.space(accepts the minibits mint;gemini-3-6-flashanswers). Others were up too butai.orangesync.techissued ansk-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 setSASQUATCH_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-onlyPOST /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 inSasquatchGuide). Pro access (useSasquatchPro) is granted by a provider-authored zap receipt, and its expiry is derived from the paid amount → tier, never the user-signedpro-expirytag. Pro & Support hub (2026-10-04): all paid extras now have one home — Settings → Pro & Support (?section=pro,components/ProSupportScreen.tsx, menu row + section inSettingsPage). It shows the honest "prepaid, not a subscription" framing, Sasquatch Pro status (days left) +PRO_TIERSwith per-day sats + a "Go Pro / Extend" button (opensSasquatchProDialog), 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=prois in the smoke list.- Pro proofs STACK (fixed 2026-09-28).
useSasquatchPronow collects ALL valid proofs and accumulatesexpiry = max(expiry, proofTime) + tierDays(renewal semantics) — before, it took the max ofreceipt.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.tsandnetlify/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.prokind-1985 labels authored by an owner pubkey, taggedp= 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-onlyGrantProCard(Settings → Advanced), written togetWriteRelays()and read viaquerySkateContent(community relay + user relays); the self-signed32671remains advisory-only. - Pro UX pass (same date):
usePersonalContextnow decrypts the Stash on demand —unlockStash()is called bygetMyStashwhen the AI actually reads it (stashRefavoids 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 draftwtb_gear("wanted" ads, NIP-99listing_type=wtb). The panel header shows⚡ Pro · Nddays left; the tier picker shows live USD viauseExchangeRate(the hardcoded ~$60k/BTCusdfield was removed fromPRO_TIERS). Copy now says skate status (not spot status) and states trip planning is free.sasquatchBrain.tscaps each request (15 s) and the whole attempt (35 s); the system prompt is a realsystemmessage; 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'mistralslug is dead (404); onlyopenai(gpt-oss-20b) is live.findCoaches+getCommunityStatscover all Lab roles (kinds 35001–35014). Bubble visibility (2026-10-04): the floating Sasquatch FAB no longer pulses forever — theanimate-pinghalo shows only when an unread tip exists, ismotion-reduce:hidden, and is suppressed on full-bleed routes (isFullBleedpassed fromLayout). 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, plususeSasquatchHidden(sasquatch-hidden, same-tab synced via asasquatch:hiddenchangeevent) and a Settings → Appearance "Show Sasquatch assistant" toggle. (Earlier iteration put an always-visible ✕ over the avatar on mobile — removed; it was ugly.)
- Pro proofs STACK (fixed 2026-09-28).
- 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_LEVELis now wired to the transport. Relays come fromNOSTR_RELAYSin the gitignoredcvm-server/.env— policy is community relay (self-hostedwss://relay.skate-spots.orgsince 2026-10-12, with the legacy hosted relay as a fallback). The tracked manifest ispackage.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'suseCVMClient. 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 bycvm-server/tsconfig.jsonvia thetypecheck:cvmscript (wired intonpm test);check:cvmruns a Bun transpile. The server runs on one external host (not this box) — deploy withnpm 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 --rollbackrestores the newest backup. The discovery relay (relay.contextvm.org) is the real single point of failure, not the server.
- ⚠️ Zod strips undeclared keys — it cost us the DM relay list (fixed
2026-09-27).
- Routing / directions:
RouteDialogis 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.orghosts 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 callhttps://routing.openstreetmap.de/routed-{foot|bike|car}/route/v1/{foot|bike| driving}/…(CORS*, direct first,proxy.shakespeare.diyproxy only as fallback), requeststeps=trueand exposeRouteStep[](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 flaggedapproximate("driving estimate") so we never pass driving numbers off as walking. Moto≈car is labelled honestly; the foot summary also shows a ~10 km/hskateTimecruise 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.csshas a global mobile rulebutton, [role=button] { min-height: 44px; min-width: 44px }for touch targets. Any dense list of<button>rows (like route steps) must addmin-h-0or every row becomes a 44px slab — a class beats the element selector, somin-h-0is enough. - Follow mode / start point / offline (2026-09-27): the dialog also does
follow mode (explicit Start →
watchPositioningeolocation.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 alwaysstop()it on close/unmount; a transient watch error (timeout/temporarily unavailable) must not tear down the watcher — onlydenieddoes (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 onvisibilitychange), speak turns via device TTS (speechSynthesis, prefer alocalServicevoice in the user's language — free/offline/no server) at ~150 m and ~40 m bands (announced once each, tracked inannouncedRef), 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 inskatespots: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 (andorder-*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 isshrink-0ath-[34vh] min-h-[220px] sm:h-[400px], orh-[54vh] min-h-[320px]/sm:h-[60vh]while following. The summary is a compact one-line strip (distance · time · arrive) —StatTilewas removed; the three ~90px tiles were eating the map's space. Owner feedback (2026-10-01): the map dominated the phone screen — it was50vh(and38vhbefore that);34vhgives 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 (keptsr-onlyfor a11y); that promise now lives on /privacy → "What leaves your device" → Directions. ⚠️ Don't go back to a singleoverflow-hiddencontent box: that made everything below the fold unreachable once the map grew. The map carries a metricL.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):RouteDialogseeds origin fromgetLastLocation()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 toproximity. - Transit detail (2026-10-01): Transitous returns more itineraries than
requested (
numItineraries: 4→ we saw 9), sofetchTransitRoutenow maps them ALL intoRouteResult.transitOptions(transit= index 0).RouteDialogshows 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, platformtrackwhen present,N stops(intermediateStops.length + 1), operator (agencyName), and a live badge only whenrealTimeis true. Mode → icon/colour is MOTIS's ownmode(BUS🚌,TRAM🚊,METRO/SUBWAY🚇,RAIL*/REGIONAL_RAIL… 🚆,FERRY⛴️). ⚠️ MOTIS labels a German S-BahnMETRO(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 aUser-Agent. Time picker + mode filter + stop boards (2026-10-01):fetchTransitRoutetakes{ time, arriveBy, transitModes }(MOTIStime/arriveBy/transitModes); the dialog exposes Leave now · Depart at · Arrive by (adatetime-localinput) and an Only row of mode toggles (nothing selected = all modes). Mode groups live insrc/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 opensStopDepartures.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 atickets ↗link toagencyFareUrlwhen the feed supplies one (usually empty). Still absent: nothing planned. - ⚠️
RouteDialoghad 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 amapDivRef.currentguard 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, andmapDivis in the effect deps so it runs when the node actually mounts. (2) Even once created, the global.leaflet-container { height: 100% }override inindex.cssbeatsh-64on 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 ish-full. (3) On the map, Directions used to render the dialog INSIDE the spotSheet(two stacked Radix modals; the sheet covered the route). The map now owns the dialog at page level (routeTargetstate) and closes the sheet first. - OSRM is called directly (
router.project-osrm.orgsendsAccess-Control-Allow-Origin: *) with theproxy.shakespeare.diyCORS 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 withscripts/-style CDP repros, not just the smoke test (which only checks mount).
- ⚠️
Layoutis a router layout —AppRouterwraps every route (except/amber-callbackand 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 (isFullBleedinLayout), not a per-pagenoPaddingprop. 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) — seeuseSyncedAppData/useStash. Read-state is local-only for interactive signers:useReadStateskips the encrypted publish whenisInteractiveSigneris 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: Infinitydoes NOT stop the FIRST decrypt (fixed 2026-09-27). That was the "Amber pops on every screen" report: the encrypteduseSyncedAppDatablobs (read-state, preferences) ran their queryFn once on mount for each racing consumer, and relay responses landing during navigation made it look per-page.useSyncedAppDatanow takesinteractiveNetwork(default false): for an interactive signer the blob is local-only — the query is not even enabled andpatch()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 setinteractiveNetwork: trueif a feature genuinely cannot work from localStorage. Diagnostic recipe: mock anx-amberlogin by writinglocalStorage['nostr:login']before load, then wraplocalStorage.setItemto log writes ofamber_pending_operation(captures every prompt + a stack). Beware stale harness profiles — use a fresh--user-data-dirper 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).
/newsletterwas stuck in an infinite render loop (~100% CPU, React "Maximum update depth exceeded") because:useSyncedAppDatareturned a fresh object literal every render →useReadState.apply(depsynced) got a new identity →markNewsletterRead/markAllReadchanged 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 inNotificationBellwhile open. Fixes (keep them):useLocalStorage's setter is referentially stable (ref + functional update),useSyncedAppDatareturns a memoized object,useReadState.applyreadssynced/interactivefrom refs so it's stable, andmarkAllRead/markNewsletterReadare idempotent (return the SAME object when the marker already coversnow, so React bails). Page effects that mark read on open use a mount-once ref guard (NewsletterPage). Diagnose with CDPPerformance.getMetricsTaskDurationover ~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 viauseSkateRelays(querySkateContent) — i.e. the community relay + the reader's relays, and for logged-out visitors only the community relay. So they must be published togetWriteRelays()(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.useSpotChatis the reference pattern. (Before the fix, an admin whose own NIP-65 lackedgoskatespotswould publish issues nobody could find.) - Weekly newsletter automation (2026-10-28).
scripts/newsletter-weekly.tsgenerates the next issue (published max + 1), builds all fields, makes a fresh 1200×630 cover from the Drop #006 foundation, uploads it to Blossom, and publishes only a draft as NIP-78kind 30078,d=skatespots:newsletter:draft,t=skatespotsnewsdraft. The admin/newsletterpage shows a Weekly draft ready banner; the composer loads it (useNewsletterDraft) and has a safe Markdown Preview. SetNEWSLETTER_AUTOPUBLISH=1in the service to also publish the NIP-23 issue + kind-1 teaser. The timer (scripts/systemd/skatespots-newsletter.timer, Monday 08:00, install withscripts/install-newsletter-timer.sh) defaults toNEWSLETTER_AUTOPUBLISH=0— review the voice/cover before enabling. The wrapper also sends the admins a content-free NIP-17 DM (push-enabled) with the issue number and /newsletter link when the draft is ready. editorial bank lives insrc/lib/newsletterContent.ts(rotating South Park jokes; 4 shipped highlights; current tech wires); issue 7 uses the curated highlights, later issues parse the top ofrelease-notes.txt. The static archive now hides an issue number that is also live, so backfilled issues don't duplicate. The draft event is public (it contains no private data); never putSIGN_WITHin it or in tracked files.
- ⚠️
- Global command palette (2026-10-28):
components/GlobalSearch.tsx+lib/globalSearch.ts; opens onCmd/Ctrl+Kor the header search button.CommandDialog(cmdk) searches static pages/nav plus spots/shops/crews/events/ gear/skaters. All data hooks are enabled only while open (the map'suseSkateSpots(2000)key is reused); recent searches live inskatespots:global-search-recent. It suppresses the desktop welcome modal on open. Map Events/Crews layer choices useuseLocalStorage;ScrollToTopnow restores scroll per history entry on POP and scrolls to top on PUSH. - Nav parity (fixed 2026-09-27): the desktop header is
max-w-[1700px](wider than the pagecontainer's 1400px) and nav items arepx-3+whitespace-nowrap; the nav keepsmin-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 insrc/lib/diyGuides.ts(DIY_CATEGORIESboards/ramps/concrete/repairs/tools +DIY_CARDS+DIY_ACTIONS);src/pages/DiyPage.tsxrenders it. Policy: link to our own app sections (to) where we have content; otherwise show the topic as an unclickablecomingSooncard — no external links. Registered as a nav-catalog item (iddiy, 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; thedeckCostcard 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 derivedwage × burdenfigure is the "Loaded rate", and the multiplier is "Markup (×)" with the implied gross margin % shown (it is not a margin). The three# boardscolumns 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 atargetMonthlyProfitgoal). New statequantity/wholesaleMarkup/targetMonthlyProfitround-trips through CSV (marginsrows);numInputclamps negatives to 0. The client quote shows× Nand the order total (the deposit is taken on the total). Advanced toggle (2026-10-05): a Simple ↔ Advanced switch (persistedskatespots: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 overquantity, 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/costNoPackUnitinclude setup, and the prices use them. All four round-trip through CSV, and the state is merged overDEFAULT_CALCon read (old sheets/presets can't produce NaN —clampPctis NaN-safe too). Currency switching also convertstargetMonthlyProfit,batchSetupCostand processrate. 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;CalcSectionsetsaria-expanded; every table row gains a duplicate (Copy) action beside delete; the sticky summary chip shows the order total whenquantity > 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. Onmax-width:639pxthe 4 data tables become stacked cards viasrc/index.css.ss-sheet-table(theadhidden; eachtda label/value row;data-labelsupplies the label throughtd::before; cellsflex-wrapso 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; adddefaultOpento aCalcSectiontemporarily 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 viaFIAT_CODES), and switching converts every money field using live BTC rates (useBtcRates→getBtcRates, one Coinbaseexchange-rates?currency=BTCcall — 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¤cy(CreateListingPage reads both params) and "Get it made →" to/lab?craft=building. Export (2026-10-05): the primary export is a PDF (lazy-loadedjspdf) — a formatted summary of every section + the selling price; the reason it's a real PDF and not print-to-PDF is thatwindow.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 (lazyxlsx), 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 inCalcState, so loading/saving presets keeps the same business name/logo/contact (the sheet is numbers only). The sheet's key isskatespots:deck-cost:v2. Nostr identity on the quote (2026-10-05):QuoteProfilealso 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 listsNIP-05 …/nostr npub…in the contact block andPay 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):QuoteProfilealso 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 isname,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-2330023witht=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.tshas a note where the card was) and/diy/deck-designeris admin-gated —DeckDesignerPagereturns<Navigate to="/diy">unlessisAdminPubkey(user.pubkey), and shows an "Internal preview" badge. To un-freeze: remove the guard inDeckDesignerPage.tsxand re-add thedeckDesignercard indiyGuides.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.linesdeltas, no path-API dependency,ensureRoomguards 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 inNAV_CATALOGas idapp-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 inMOBILE_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.svg55×86 mm,…-poster.svgA3), regenerated withnode 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)
CreateSpotPagerequires ≥1 photo (guard inhandleSubmit+ 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 +useSpotStatusreporterCount(kind 2259). Community confidence aggregation (2026-10-03):src/hooks/useSpotCommunityScore.tsdoes 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. Returnslevel: unverified | seen | community+ breakdown;SpotDetailPageshows a "why" line (2 photos · 1 zap …). The weather score was renamed "Conditions · weather only" (inSpotDetailPageand theSpotMappopup) so it can't read as spot quality. (3)useNotASpotReport= one-tap NIP-56 (1984) report taggednot-a-skate-spot(NIP-56otherreason + ourL/l/ttaxonomy), used from the spot's "More" menu and the sharedReportDialog. - Admin hide + review queue (2026-09-27):
useAdminModerationalways READ NIP-32 kind-1985hiddenlabels, but nothing published one — the only working hide was device-local (useDeleteEntity→addLocalHidden).useAdminHideis 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-onlyAdminReviewCardin Settings → Advanced. Hiding is soft and reversible — the event stays on relays (honest for a censorship-resistant network); nothing is deleted./reviewis in the smoke-test route list.- Symmetric verdicts + richer signals (2026-09-27):
MODERATION_LABEL_VERIFIEDis the positive counterpart tohidden.useAdminModerationfetches both labels in ONE query and exposesverifiedCoords/verifiedIds/isVerified()(plus a local mirror viaaddLocalConfirmed/getLocalConfirmed), anduseAdminVerify()(same file asuseAdminHide) 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 (useBatchSpotStatusreporterCount) → 'mapper not in your web of trust' / 'no community signals'. The flag logic is shared insrc/lib/spotQuality.ts(used by both the page andAdminReviewCard, 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#aquery 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.useAdminModerationno longer unions labels; it keeps the newest verdict per target (bycreated_at, with device-local verdicts overlaid by timestamp), so publishingclearedsupersedes an earlierhidden/verified.useAdminClear()publishes it. The local mirror is now a verdict map (skatespots_local_admin_verdicts_v1, key = coord ore:<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.
- Symmetric verdicts + richer signals (2026-09-27):
- Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):
three gaps closed in one pass.
- Reports were a void.
ReportDialogpublishes 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. NowuseSpotReportsbatch-readskinds:[1984], '#e': [all spot ids]in ONE query (viaquerySkateContent), and the review queue ranks reported spots ABOVE the heuristics (redREPORTED: …chip).useNotASpotReportnow publishes togetWriteRelays()(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. - 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" (dupConfirmedRefbypass). Designed for the clone-spam failure mode. - Contributor feedback:
useSkateSpotno 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.SpotDetailPageshows an author-only banner ("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public labels, so there is nothing secret being revealed.
- Reports were a void.
- Review queue covers every content kind (2026-09-28):
/reviewnow has tabs for spots / shops / events / tours / gear. The hide/verify labels were always kind-agnostic (a/etags), so the work was: (1) add{ includeHidden }(admin-tooling only) touseSkateShops,useSkateEvents,useToursanduseMarketplaceItems— same reason as spots; (2) generalise the flags withcontentFlags()insrc/lib/spotQuality.ts(spot-specific signals — trust/status/photos — stay inspotFlags); (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 anip05ending@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-0badgesarray ({label, url?}), rendered as clickable chips (external links). The legacy newlinesponsorsfield is carried forward into badges (parseBadgesreads it;EditProfileFormnow writesbadgesand deletessponsors). Shown on the profile page (NIP19Page): the app badge in the header, user badges in a "Badges" card. ⚠️NostrMetadatahas nobadgesfield, so it's read via a cast (metadata as … & Record<string, unknown>), likesponsors/cordnbefore 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
useAutoHiddenSpotshides 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 admincleared/verifiedverdict, brings it back).useSpotReportsnow exposesreporters(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 separateorg.skatespots.spotclaimnamespace; an admin publishesclaim-approved(moderation namespace,a=coord +p=claimant); the claimant then re-publishes the spot withclaim-reason=private-approvedto become the owner.useSpotClaims.ts(useSpotClaims/useApprovedSpotClaims/useAllSpotClaims/useSubmitSpotClaim) +useAdminApproveClaim; UI inSpotOwnership.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.handleMapClickdrops apendingAddPin(rendered bySpotMap'spendingPinprop) 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.pickOnMapwas 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 viauseFormDraft(generalized fromuseLabDraft, same resume-banner pattern). ⚠️ Leaflet's base CSS and marker icons are self-hosted now (2026-10-12) — the CSS is imported insrc/main.tsx(leaflet/dist/leaflet.css,MarkerCluster*.css) andSpotMap.tsximports the marker PNGs fromleaflet/dist/images/, so there's no unpkg/CDN request and the CSP droppedunpkg.com(in all three copies). 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 sameuseSkateSpots(2000)cache key as the map (was a seconduseSkateSpots(500)query); and publish caps the reverse-geocode atGEOCODE_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.tsxused toclearLayers()+ rebuild every marker whenever the parent handed it fresh arrays, and added each marker with a per-markerclusterGroup.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 bulkaddLayers()inrequestAnimationFramebatches 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/showGearare now effect deps. - On-device OS notifications (Phase 1, 2026-09-29):
src/components/OsNotificationBridge.tsx(mounted once inLayout, 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 issrc/lib/osNotify.ts: webNotificationAPI, native@capacitor/local-notifications(lazily imported; the dep was added socap syncregisters it — an installed APK must be rebuilt to pick it up). Prefs are device-local (useNotificationPrefs, keyskatespots: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+useLocalRemindershand 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 whenprefs.remindersANDisNativePlatform(). 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 inskatespots:notifications:reminders; the hook cancels them all on unmount (opt-out / logout). New pref keyreminders(default on). Pro-expiry reminder deliberately skipped — it would pull in the heavyuseSasquatchProquery. ❌ Phase 2 (background runner) is DEAD — don't attempt it (verified 2026-09-29): Capacitor's Background Runner supportsfetchbut NOTWebSocket(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.useResolvedDmRelaysis 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) withweb-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-timechallengetag). State = JSON file; VAPID keys from env. Wired like the other standalone servers:typecheck:push+check:push(both innpm 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 inNotificationSettingsCard, anduseSendDirectMessagenow 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#pfilters 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 unitskatespots-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 servesai.skate-spots.org). Verifiedhttps://push.skate-spots.org/health→ 200. ⚠️ 000 from this box = a stale local resolver cache (NXDOMAIN); confirm withcurl --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-notificationswork was reverted (commit68c12a2); 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, packagecordn-background) does native background polling via a custom Capacitor plugin (Kotlin + Rust/UniFFI) with astandard(WorkManager ~15 min) and afast(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-servicehas/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()callsrecordActivity(). Plugin WRITTEN (2026-10-01):packages/skatespots-background/— a local Capacitor Android plugin (Kotlin:SkateSpotsBackgroundPlugin,PollWorker[WorkManager],PollService[foreground],Poller,Notifier,PollPrefs) plussrc/hooks/useBackgroundDelivery.tsand a Background delivery (Android) control inNotificationSettingsCard(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 thefile:dep +cap sync. ⚠️ UNVERIFIED — needscap sync+ a device.android/is regenerated, so thefile:dep must stay inpackage.jsonforcap syncto add the module;build.gradle/manifest may need tuning per the app's Capacitor version. Category-aware (2026-10-01):/subscribeand/native/poll/registercarrycategories; the server stores them per subscription/token and only pushes matching ones (DM gift-wrap →dms; offer/counter/accept/attestation/ shipment →trades—social/crew/newsletterare in-app only, never pushed)./pendinguses a per-category activity breakdown, so it only moves for the token's categories.NotificationSettingsCardre-sends the choices on toggle change (push.resubscribe()/background.resubscribe()). ⚠️ An absentcategories= legacy (allow all); an empty array = none. ⚠️ Androidstandardmode is best-effort (Doze/OEM battery managers defer it);fastneeds 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), andsocial(zap 9735 / reply-mention 1 / comment 1111) viaCATEGORY_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)callspush.disable()+background.disable()).
- systemd unit in
- 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
- 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 ishttps://cordn.net/chat/coordinators?c=<nprofile>(Cordnpackages/server/src/main.ts); default relaywss://relay.contextvm.org; self-host is thedocker run … ghcr.io/cordn-msg/cordn:latestone-liner. Files:src/lib/coordinator.ts(parse/encode/add-url/name),src/hooks/useCoordinator.ts(device-local store keyskatespots:coordinatoruseCoordinatorHealth),src/components/CoordinatorSettingsCard.tsx, Settings → Coordinator (?section=coordinator). Health is a presence probe, not Cordn RPC: query the coordinator's relays forauthors:[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_URLcurrently points atcordn.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-0cordnfield (app convention, documented in NIP.md — not a new kind).EditProfileFormaccepts 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".NIP19Pagerenders it as a Coordinator row with a one-tap Add (sets the viewer'suseCoordinator, with toast) plus an "Open in Cordn" link — falling back to the old plain-link row when the value doesn't parse. ⚠️ STATUS (2026-10-13): the sealed-relay chat path described in this bullet is now UNWIRED — the crew Chat tab is always NIP-17, and coordinator routing lives only in the crew's Private chat (MLS).useCordnChat+lib/cordn/{client,crypto}+useCrewChatUnreadare kept but unused (headers marked); seeCREW_CHAT_AUDIT.md. Do not re-wire the old path without reading that. The rest of the bullet is the historical design. ⚠️ 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'scoordinatortag['coordinator', <url>, <coordinatorPubkey?>, <operatorPubkey?>](SkateCrew.coordinator, parsed intypes.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 —CrewFormnow uses the sharedparseCrewCoordinatorInput/crewCoordinatorInputValue(lib/coordinator.ts) and has a "Use my coordinator" button that prefills from Settings → Coordinator. ⚠️ Transport reality (do not overclaim): a crew'swss://coordinator URL is used AS A RELAY (resolveCordnEndpoint→relayUrl); anhttps://one falls back to the publicwss://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 anhttps://coordinator puts ciphertext + metadata (crew coord, timing) on a public relay.CrewCoordinatorCard+CrewFormnow say this honestly (2026-09-29). Still future: real MLS (ts-mls) instead of group-key crypto, plus Cordn RPC interop. Lesson: grepsrcfor the feature before building it — this was nearly duplicated.
- Cordn native — real MLS (Level 2 foundation, 2026-10-12):
src/lib/cordn/native/is a browser-safe Cordn client doing real RFC 9420 MLS (not the sealed-relay stand-in above), mirroring Cordn's wire contract — reimplemented, we do not copy@cordn/cli. Files:mls.ts(identity, key packages, group create/add/join, application messages, the ChaCha20-Poly1305 coordinator wrapper, causalprevtags),coordinator.ts(ContextVM client over@contextvm/sdk, validated with@cordn/coreschemas),session.ts(CordnSession:generateKeyPackage/publishKeyPackage/createGroup/addMember→Welcome/joinGroup/send/fetch; persistence behind aCordnStore,MemoryCordnStoretoday — IndexedDB adapter still TODO) andindex.ts. Deps added:@cordn/core,@contextvm/mcp-sdk, and@noble/cipherspinned 2.2.0 (the tree wasinvalid:ts-mlswants 2.2.0 while a stale root copy was 0.5.3). ⚠️ Verified end-to-end against our own coordinator —CORDN_LIVE=1 npx vitest run src/lib/cordn/native/native.live.test.tsdoes a two-member round trip (A creates group + adds B → Welcome → B joins → A↔B messages encrypt/decrypt with proven senders). ⚠️ That live test runs in the node vitest environment (// @vitest-environment node) — jsdom's realm makes aUint8Arrayfailinstanceofinside the SDK's nested nostr-tools/@noble/hashes;src/test/setup.tsis now guarded withtypeof window !== 'undefined'so node-env files collect. Not wired to any UI yet — the app still uses the sealed-relay crew chat. Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request flow. Nothing imports the native module, so the web bundle is unchanged. Milestone 2 landed (2026-10-12): the session now runs on the viewer's own signer (NIP-07/Amber/nsec — no key held;CordnSession.open({ pubkey, signer })), so the Cordn identity IS the user's pubkey. AddedidbStore.ts(createCordnStore(pubkey)— Dexie DBSkateSpotsCordnDB, one snapshot per account, survives reload) and the membership delivery methods:requestJoin/fetchJoinRequests/fetchWelcomes/acceptWelcome/addMemberByIdentifier(+ autowelcome_storeinaddMember). Verified live with a 3rd scenario (join-request → approve → welcome); ⚠️kp_takeaccepts the requester's pubkey (and akp_refalso worked). UI:hooks/useCordnNative.ts(opens/owns the session; lazy-imports the native module so@contextvm/sdk+ts-mls land in their own ~678 kB async chunk, NOT the 1.9 MB entry) +components/crew/CordnNativePanel.tsx, rendered as a third "Private chat" tab on the crew page (founder/admin create the group + approve requests; members ask to join + check for an invite). Strings ini18n/legacy/cordnUi.ts. ⚠️ The React panel isn't click-tested (no automated login+crew flow); the native stack's browser runtime IS — see milestone 2b. For interactive signers (Amber) polling is off (each ContextVM request would pop) — manual Refresh/Send, same rule as the DM hooks. Milestone 2b (2026-10-12): ① browser runtime proven —node scripts/cordn-browser-probe.mjsservesdist/, drives headless Chromium over CDP (same harness assmoke-test.mjs), imports the built native chunk, and runs a real session against the coordinator (publish key package → create group → send), then reopens from the Dexie/IndexedDB store (1 group, 1 message restored). It is NOT innpm test(needs network + Chromium). ⚠️ The barrel chunk exports its namespace under a mangled name, so the probe picksns.CordnSession ? ns : ns.a. ② Group ref sharing — the panel shows thecordn1…ref (encodeGroupRef({ gid: crew coordinate, coordinatorPubkey, relays }), re-exported from the native barrel; computed in the hook) with a Copy button, so the group can be opened in Staircase/cordn-web. ③ Images — the composer has an attach button (useUploadFile→ Blossom), and the URL rides inside the encrypted MLS envelope (['image', url]tag),CordnChatMessage.imageUrl, rendered withImageWithFallback. Verified in the live test (image URL decrypts to the other member). ⚠️ Unread badge for the tab is deliberately NOT built — it needs a background signed request (pops on Amber) or a poll; revisit only if the feature gets real use. ④ Tab promotion + nudge — the crew page defaults to the Private-chat tab when this device already holds the group (useCordnGroupExistsreads the IndexedDB snapshot: local only, no signer), and a founder/admin banner ("Set up") points at the tab when it doesn't. A manual tab click always wins (tabTouchedRef). ⑤ Messages hub entry (2026-10-12):DirectMessagesPagehas a "Crews" section listing the viewer's crews (useUserCrews) with an Open/Set-up badge (fromuseCordnGroupGids— one local IndexedDB read) linking into/crew/<pk>/<id>?tab=mls. The crew page's tabs are now URL-addressable (?tab=events|chat|mls; an explicit param wins over the auto-default). This is the cheap "pointer" integration — the two transports are NOT merged (DMs stay NIP-17; the coordinator is only the crew MLS group). UX polish (2026-10-27): after "Ask to join" the panel auto-checks the coordinator every 10s→30s (silent signers only; Amber stays manual), and stops when the welcome lands. The empty state has 3 plain steps + "only crew members can read this"; Cordn/group ref/key package moved under Advanced. The status chip is actionable (Retry), failures show a plain message + details. A device with no key package gets "Set up this device" (requestJoinpublishes a fresh package). The member list shows avatars + founder/admin/ member roles; join requests load on open and show avatars. Messages show Sending…/Sent/Failed with Retry (optimistic pending bubble). Invite opens a dialog with QR + copy/share and a?tab=mlslink; on native it useshttps://skate-spots.org(nothttps://localhost). The founder banner is dismissible per crew (skatespots:cordn-banner-dismissed:<pk>/<id>).useCordnNativeaddsretry,keyPackageReady, booleansend/createChat/requestJoin/approve, and filters welcomes by gid so one crew can't adopt another crew's chat. Coordinator announce relays are now["wss://relay.skate-spots.org"]only (removed purplepag.es/nostr.band/nos.lol timeouts; logs showlocal[10002=ok]). Advanced also exposes the Cordn share linkhttps://cordn.net/chat/<cordn1…>(open/copy). Per Cordn's group-ref spec thecordn1…ref is a share primitive: a client can publish a key package and submit a join request, then the founder approves. The reliable "join us" link for normal users is stillhttps://skate-spots.org/#/crew/<pk>/<id>?tab=mls; use the Cordn link for Staircase/Cordn clients. ⚠️ cordn.net's web client still stalls after NIP-42 (upstream issue), so it is not the primary onboarding path yet. - SkateSpots Community room (2026-10-27):
/communityis a standaloneCordnRoom, not a crew. Its gid is the stable literalskatespots:community(no owner key in the gid), so the bot key can rotate without changing links. Owner/crew.pubkey= the botf978…f0, admins =ADMIN_PUBKEYS+ bot. The page passescanCreate={false}: everyone (admins included) uses the normal "Ask to join" flow; the bot auto-approves. After joining, admins still get the requests panel as a fallback. Links:/#/community(app/QR) and the Advancedhttps://cordn.net/chat/<cordn1…>for Staircase/Cordn clients. Entry points: nav catalog/burger + About feedback card.- Bot:
scripts/community-bot.ts, built to CJS withscripts/build-community-bot.sh(Node 20 needs thewsglobal shim), run byscripts/systemd/skatespots-community.serviceon the relay box:/opt/skatespots-community/community-bot.cjs, env/etc/skatespots/community.env(600), state/var/lib/skatespots-community/state.json(600). It created the room once withCOMMUNITY_CREATE=1; leaveCOMMUNITY_CREATE=0afterward — a lost state plus create=1 would fork the room. It polls every 15s, auto-approves up to 10 requests/cycle, skips its own request, and backs off failed approvals for 5 minutes. - Secrets/backup: bot key in
~/.config/skatespots/community.env(600) and the VPS env; state copy in~/.config/skatespots/community-state.json(600). Never commit/print either. - Copy:
communityUi.*(en/es + all 24; 100%); bot rows are labeled "Community bot" by pubkey, no profile event required.
- Bot:
- Cordn native devices/members/benchmark (2026-10-28): a new device can
publish a key package and show a
cordn-device:QR; an existing member scans it (QRScannerModal), callsaddMemberByIdentifier(gid, kpRef), and the new device'scheckInviteaccepts the Welcome — no join request/founder round trip. Member leaves can be removed byleafIndex(removeMemberFromGroup→createCommitwithdefaultProposalTypes.remove). Inline promote/demote (2026-10-28): the founder gets a shield button on each non-founder member row; it republishes the existing crew event tags with the target'sptag switched between plain andadmin(only the founder can do this — an admin's republish would fork the crew). Messages have copy/reply, reactions (👍/❤️/🔥/😂), delete-for-everyone, and read receipts. These are encrypted MLS control messages (empty content +reaction/unreact/delete/readtags) sent viasendControl;useCordnNativefilters them out of the visible list and derives reaction/read maps. Delete is a local tombstone, not a relay hard-delete; automatic read receipts are silent-signer only (Amber would prompt). Live benchmark (scripts/cordn-benchmark.ts, 30 msgs): ~489ms/send, ~20ms/fetch, snapshot ~13.8KB (~460 B/message). - Cordn join-request push (2026-10-28):
scripts/cordn-join-watcher.tsruns every minute (user timer) and polls the coordinator for known crew gids. New requests become (a) a NIP-17 DM to the founder/admins (push service wakes the phone) and (b) an encrypted NIP-78 notice (t=cordnjoinrequest, authored by the dedicated watcher key82ce71cf…).useCordnJoinNotificationsdecrypts it; the bell's Crews/All tabs show aCordnJoinRequestCardwith Review/Dismiss, and count it byseenAt. Key~/.config/skatespots/cordn-watcher.env(600); state~/.local/state/skatespots-cordn-watcher/state.json. Crews with a custom coordinator are skipped (only the SkateSpots embedded coordinator is queried); approved requests leave the coordinator but the bell card remains until dismissed. - Map site-links strip (2026-10-05): the desktop footer is hidden on the map
(full-bleed), so
components/MapFooterStrip.tsxsurfaces the same links (DIY/Newsletter/License/Privacy + "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). Always visible (2026-10-28): the old dismissibleuseMapFooterHiddenbehavior/setting was removed — it was easy to hide the only footer on the map forever and then report it missing. The map's OpenStreetMap attribution moved tomd:bottom-7so the bar doesn't cover it. - Map layout — controls float (2026-09-29): on mobile the
MapPagetoolbar isabsoluteover the map (pointer-events-nonewrapper + gradient +pointer-events-autoinner) so the map fills the whole container;md:staticrestores the in-flow bar on desktop. The container height subtracts header + bottom nav only (the old desktop--footer-desktopsubtraction is gone) and the desktop footer is hidden on full-bleed routes (Layoutfooter: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 isabsolute top-0inside 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:staticis unchanged on desktop. ⚠️ The floating toolbar sits over Leaflet's default top-left zoom control, soMapPagepassesclassName="h-full ss-map-floating-toolbar"andindex.cssoffsets.leaflet-top.leaflet-leftby4.25remonly undermax-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.
LoginAreaalso rendered aSignupMethodDialog("Recovery Phrase vs Secret Key") +MnemonicSignupDialog, butsetSignupMethodDialogOpen(true)existed nowhere — the whole method branch was DEAD code (unreachable). Removed the wiring (files kept on disk); the misleadingnsecSignupDialogOpenstate was renamedsignupDialogOpen.SignupDialog(welcome → profile → done) is the only signup path. SignupDialoggained 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" callsgetCurrentPosition()(which alsosaveLastLocation()s), soMapPage's mount-timegetLastLocation()opens centered on their area. The logged-out wallet section's dead disabled "Log in first" button became a plain line. - Desktop welcome sign-in modal (2026-10-26):
Layoutopens the themedLoginDialogonce for a logged-out desktop visitor (non-native,min-width: 1024px, after ~900 ms) as a welcome/sign-in offer; dismissal is remembered insessionStorage['skatespots:desktop-welcome-snoozed'](per session, not forever — a single ✕ must not suppress it permanently), and it also offers the normalSignupDialog. It reuses the existing methods (browser extension, bunker/signer, Amber on Android, nsec, and the NostrConnect QR). 3-card restyle (2026-10-26):LoginDialognow leads with a three-card chooser — Browser extension / Signer (Bunker on desktop, Amber on Android) / Sign in with your phone (NostrConnect) — plus a quiet Secret Key link; the cards are a controlled tab chooser (loginTabstate) over the existing panels, and the NostrConnect QR moved from the bunker tab into its ownconnectpanel. Compact pass (2026-10-26): the dialog is now scroll-safe (flex flex-col+max-h-[calc(100dvh-2rem)], contentmin-h-0 flex-1 overflow-y-auto— it used to clip the bottom links), the New-here block is a compact row (one dominant action per state), bunker instructions are a collapsed<details>("Where do I get a bunker?"), and the secret-key option is a quiet bottom link. Labels are plain ("Browser extension · Signer app · Sign in with your phone") with NIP-07/46/55 as tiny hints (settingsUi.loginDialog.{extensionCard,signerCard,secretKeyLink, whereBunker,welcomeSubtitleBrowse}; backfilled to all 20 locales). Spacing is an 8px grid:px-5(20px) gutters,space-y-4(16px) between sections,gap-2(8px) between cards,p-2.5(10px) card padding,pt-5/pb-3header — a 448px max width keeps 3 cards ≥~120px each while staying a single focused column. Verified with headless 1280×900 and 390×844 screenshots. ⚠️SignupDialogis a default-only export, so lazy-load it aslazy(() => import('…/SignupDialog')). - Pass the app on — Android only (2026-10-26): the About page renders
ShareAppCardwhengetPlatform() === 'android'(APK/Zapstore; hidden on web/iOS): a QR of the Zapstore listing (APP_INSTALL_URLinlib/platform.ts, single-sourced), a share-sheet button (Nearby Share/SMS/etc.), andNfcWriteButtonto write the install URL to an NFC tag. ⚠️ Deliberately no peer-to-peer APK transfer — that needs native Wi‑Fi Direct/BLE + aPackageInstallerflow and real-device QA; Zapstore handles installs/updates. (Not visually verified on-device yet.) - First-run checklist + backup-reminder gating (fixed 2026-10-12). Two
onboarding prompts nagged returning users:
FirstRunChecklist(map) showed for any logged-in user until they tapped ✕ — returning skaters saw it forever. It now requires a recentsignup_completedstamp (set by the signup flow — imported/older accounts never match) andfirst-run-seen(set once, on first show; captured at mount so it still shows that session but never again).- The
SignupDialogmarked backup done withsetBackupConfirmed(true)keyed onuseCurrentUser().pubkey— butuseris still settling mid-signup, so it wrotebackup-confirmed:anonand the post-signupBackupReminder(real pubkey) never learned it → it nagged every session. NowmarkBackupConfirmed(pubkey)(inlib/backup.ts, mirrors the hook's JSON encoding) is called with the pubkey derived from the freshly minted nsec (getPublicKey(sk)captured increateAccountSilently). ⚠️ Any new "backup done" write must use the real pubkey, notuser?.pubkeyduring signup. (The NIP-49 export password field also switchedautoCompletenew-password→offto cut the browser's save-password prompt.)
- Signup is one fork.
- Empty-map nudge (2026-09-28):
MapPagedistinguishes 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 offmapSpots: that is derived FROMfilteredSpots, so it is 0 whenever filters hide everything. - Report inbox + bulk undo + crews/Lab moderation (2026-09-28): finishing the
moderation gaps.
/reviewnow defaults to a Reports tab:useRecentReportsfetches recent NIP-56 (1984) reports and resolves the referenced event by id (one extraids: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 (addedincludeHiddentouseCrews/useLabProfiles); because hiding those removes a community or a person's listing rather than one pin, their Hide button goes through anAlertDialogconfirmation (confirmHide/confirmHideNoteonQueueList). 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→ editapps.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 apr/-prefixed branch;ngit pr list|view|apply. Our PR:bbb27a91…"Add SkateSpots", open. The gallery images live atpublic/images/screenshots/{map,spot,community}.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)": pastencryptsec1…+ 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 overnostr-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-ncryptsec1input 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 hasNLogin.fromBunker/NConnectSigner, where the client already knows the signer pubkey and sendsconnect. nostrconnect is the reverse: the SIGNER sends the firstconnectrequest to the client's ephemeral pubkey and the client must reply with the shared secret.startNostrConnect(nostr, …)mints an ephemeral keypair + secret, buildsnostrconnect://<clientPub>?relay=…&secret=…&name=…&url=…&image=…, subscribes to kind24133#p:[clientPub], and on the signer'sconnectreplies{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 derivedbunker://URI to the existinglogin.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 relayswss://relay.nsec.app,wss://relay.damus.io,wss://nos.lol. UI: LoginDialog → Bunker tab leads with the QR; thebunker://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.
- NIP-49
- Mobile grid overflow gotcha (2026-10-04): a Tailwind grid must declare a
base column —
grid grid-cols-1 md:grid-cols-2, notgrid md:grid-cols-2. Without the base, mobile renders one implicitautotrack that can exceed the container, so a long name (e.g. a/labProfileCard, or/statsTop Mappers) pushes the whole card off the right edge and clips the values. Fixed repo-wide; also addmin-w-0to truncating flex/grid children./statsalso gained a Top Shops leaderboard (topShopsinuseGrowthStats;PERSIST_BUSTER→ v6) alongside Top Mappers (spots). - Stretched-link card gotcha (2026-10-12):
SpotCardrenders its whole-card navigation as an absolutely-positioned<Link className="absolute inset-0">behind the content. It must bez-10(notz-0), and the card must not carrybackdrop-blur— az-0link under the content, withbackdrop-blurmaking the card its own paint layer, meant no click ever reached the link and the card looked dead. Hover moved to the wrapper (groupon the wrapper +group-hover:on the card/image), and the card's action row isz-20so its buttons stay above the link. Keep this shape when editing card components. - Dropdown menu scroll gotcha (2026-10-08): the shared
DropdownMenuContent(components/ui/dropdown-menu.tsx) shippedoverflow-hiddenwith 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 carriesmax-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 stopskeyDown/pointerDownpropagation so Radix typeahead does not hijack typing) and the inline/mobile variant; keysnav.searchLanguages/nav.noLanguageslive in en/es only (other locales fall back). The SettingsLanguageSelectorgrid is searchless by design. - Menu audit (2026-10-27): rendered EN/DE menus at 390×844 and 1280×900.
Alignment was already start-aligned with no clipped labels. Fixed the real
problems: the mobile burger's inline 28-language list made the sheet
3,168px tall and buried the picker — it is now a Language row + dedicated
sub-view (Back/search/list), cutting the main sheet to ~1,384px. Language
dropdown rows are
min-h-11; the mobile user menu is 44px; desktop More is 40px. Language search only auto-focuses forpointer: fine(no phone keyboard covering the list). Crew tabs use1.1fr / 1fr / 1.3fr+ 13px labels so “Private chat”/“Privater Chat” no longer truncate. Re-check withnode scripts/smoke-test.mjs+ a menu screenshot if you change the header. - 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 trackedquerySkateContentwrapper) and returnspartial: 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=trueexcluded), completed trades (31871fromuseTradeAttestations,s=revokedexcluded),tradedSatssummed from the self-reportedamounttag,tradesByMethod; regions —topCountries/topCitiesbuilt from each spot or shop's owncity/countrytags. Anything without one is surfaced as anuncategorisedrow, 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: thecountry_codethe 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 anotherCountries/otherCities"Other" row, and the heading reads "Spots by Region" — it counts spots, not people; and a bounded zap signal —zapCount/zapSatsfrom kind-9735 receipts#pthe top ~25 contributors (amounts decoded withgetSatoshisFromBolt11). ⚠️ 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 (adisplay:contentswrapper toggledhidden lg:contents, so children stay real grid items) with a "Show all 13 crafts" toggle; container widenedmax-w-6xl→max-w-7xl;StatCardgained acompactprop (+compactNumber) so big sats don't overflowtext-4xl. - ⚠️ Adding a field to a PERSISTED query is a crash risk (2026-10-01).
growth-statsis in thequeryPersisterallow-list, so a cache saved before a new field existed lacks it — andstats.topCountries.lengththrows (Cannot read properties of undefined), slamming/statsfor anyone with an old cache. Fix: bumpPERSIST_BUSTER(src/lib/queryPersister.ts, wired inApp.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 is now CANONICAL for spots + shops (owner decision, 2026-10-26).
useSkateSpotsanduseShopsusequeryCanonical(community relayrelay.skate-spots.orgONLY) with a stable'canonical'query-key suffix, so the map always matches the community dataset regardless of the viewer's personal relays. This supersedes the 2026-10-01 note below (which kept the map blended so auseSkateRelay=falseuser still saw their own pins). The spot detail page (useSkateSpot) stays blended so shared links to a spot published only on a user relay still resolve. Remaining map↔stats deltas: 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. The map and the stats legitimately differ — do NOT "fix" the map to(superseded above)queryCanonical(2026-10-01).- Moderation now applies to the stats too (2026-10-01):
useGrowthStatscallsuseAdminModeration()and drops hidden content insidededup(), so a spot the community removed no longer inflatestotalSpots, 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 atMapPage:847; the stats can't know about reports without querying them).
- ⚠️ A relay outage used to render a board of ZEROS as truth — every
sub-query ended in
- Anti-fake-volume on
/stats(2026-10-01): the marketplace "Sats Traded" used to be a naive sum of the self-reportedamounttag on31871, so two colluding accounts could inflate it for free.useGrowthStatsnow computestradedSatsrobustly — mutual trades only (a listing attested by ≥2 distinct pubkeys), per-pair cap 3, amounts >1 BTC dropped as junk — plustradedCount(mutual trades) +tradedTraders(distinct people), and keepstradedSatsClaimed(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:useTradeReputationnow exposeswithPaymentProof— mutually-confirmed trades that carry aprooftag — surfaced onTradeReputationCardas "with payment proof (self-reported — not verified)", never as verification. The one verifiable tier (2026-10-01):["proof","zap","<receiptId>"]is checked bysrc/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.)
- decodable bolt11 amount + the embedded 9734 request) and counted as
- 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()triesnominatim.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/privacyin the same change. ⚠️EditSpotPageused to DROP region tags (fixed 2026-10-01): likeEditShopPageit rebuildseventTagsfrom scratch, so anycity/country/country_codethe spot already had were silently lost on save (the new addressable event replaced the old one without them). Both edit pages nowreverseGeocodeRegions()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/statsuntil 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 acity/countrytag. 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. SouseRegionBackfill.ts+RegionBackfillCard(Settings → Advanced, admin-only) find the logged-in user's OWN untagged spots/shops, decode each pin's coords (lat/lonelseg),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 stalecity/country/country_codereplaced. Publishes togetWriteRelays()(community relay- author) so canonical
queryCanonicalstats see it, then invalidatesskate-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.
- author) so canonical
- Read-time derived regions (2026-10-01): so the region card matches the map
with NO signing,
useGrowthStatsfalls back to a cached reverse geocode of an untagged pin's own coordinates (lib/derivedRegions.ts, localStorageskatespots:derived-regions-v1, keyed by ~110 m-rounded coords). AuseDeriveRegions()hook mounted on/statsscans the community relay for untagged spots/shops, fills cache misses, and invalidatesgrowth-statsevery 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 BigDataCloudreverse-geocode-client(no key, CORS*on bothapi.bigdatacloud.net→ 307 →api-bdc.io, EnglishcountryName/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 realcity/countrytags. Either way the card'sUncategorisedrow now means "couldn't be tagged OR resolved".
- Admin backfill for legacy untagged pins (2026-10-01): the reason
- 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 Functionnetlify/functions/nip05.mjs(/.well-known/nostr.json+/api/nip05/{check,claim,release}), backed by Netlify Blobs (@netlify/blobs, storenip05, 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 thex-nip05-authheader, signed by the pubkey that will own the name), andonlyIfNewwrites so two claims can't clobber each other. Platform names are seeded in code (skate-spots/skatespots→ owner pubkey,skater→ the second admincaba497f…) and cannot be claimed/released by users; a smallRESERVEDlist 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), andsrc/components/SkateNip05Input.tsx(debounced availability + Claim). Wired into signup (SignupDialogprofile 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 advertisesnip05once the registry confirms it) and into Edit Profile (Nip05Fieldgains a "claim a free handle" block).Settings → NIP-05links to it. ⚠️config.pathDOES 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-shopsare allow-listed inqueryPersister.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 theuseAdminModerationhidden-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) ande:<id>, written optimistically inuseDeleteEntityon the owner path (rolled back if signing/publish fails). The sharedfilterAdminHidden/filterAdminHiddenMixednow 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.useSkateSpotreturns null for a tombstoned spot.cacheSpots/cacheSpotrefuse 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. Adminhidewas already instant vialocalModeration's local verdicts; this closes the NIP-09 self-delete gap. - Map search autocomplete (2026-10-09): the map search pill now shows a
suggestions dropdown.
src/lib/mapSearchSuggestions.ts(buildSearchSuggestions) builds two groups from the items that already match the active filters: spots (title matches; click = fly to + open the sheet) and places (distinct city/region/country tokens split out of each item'slocation; click = set the query to that token, e.g. "spain" → "Spain").components/MapSearchSuggestions.tsxis presentational only —MapPageowns the list andactiveIndex, plus ArrowUp/Down, Enter and Escape. New i18n keymapPage.searchSuggestPlaces(en/es only, others fall back); the Spots header reusesnav.map, the empty state reusescommon.noResults. The filter itself still matchestitle/location/description, so a place suggestion works by putting that token into the query. - Offline / dead-relay behaviour (2026-09-28): the app is a utility, so a
relay outage must degrade, not blank. ⚠️
querySkateContentused 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/queryCanonicaltake an opt-inrequireAnythat 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/useSkateSpotcatch 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).shouldDehydrateQuerynever persists an empty array — otherwise one blip would wipe the persisted map for the next 24h.MapPageshows "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 bydeviceOffline(!useOnlineStatus()) only — it used to also key off the singleSKATE_RELAYprobe, 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 callrefetchSpots()andrelay.recheck().- Offline WRITE queue is now real (2026-09-29).
lib/offlineCachealready had apendingPoststable +useSyncPendingPosts/useQueuePost+ anOfflineIndicatorbadge, but nothing ever calledqueuePost— offline writes just threw. Now:useNostrPublishqueues onisOffline()(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;useSyncPendingPostspublishes it directly (nostr.event), not throughuseNostrPublish(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 (2259spot status,24899spot chat) are pruned after 15 min (late "I'm here" is worse than none); cap 100;getPendingPostsreturnspendingand retryablefailed(a single failure must not strand an action) and the count includes both; a module-levelglobalSyncinglock keeps ONE publisher even thoughOfflineIndicator(which owns the sync) is mounted twice; retries every 30s while offline→online. ⚠️useNostrPublishnow 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 (offlineCachev2stashtable —cacheStashEvent/getCachedStashEvent), and both the read query andfetchLatestStash(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 & transfercard, 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.tswraps the export in a version-2 JSON envelope —passwordmode (scrypt NIP-49 params 2^16/r8/p1 → AES-256-GCM, header bound as AAD so KDF/ cipher can't be downgraded) ornip44mode (encrypted to your own key, no password). Plaintext v1 files still import.StashBackupExportDialog+StashBackupUnlockDialog(components/StashBackupDialogs.tsx); the picker prompts for a password only forpasswordfiles. ⚠️ 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 includessize; 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 inAddItemDialog) publishes a real NIP-99wtblisting (useStashShowcaseSync.buildListingTags— thestash-wanttag instead ofstash-showcase,listing_type=wtb,price= what they'd PAY), so sellers find it in the Marketplace (which already filterslisting_type=wtb), while the encrypted Wishlist tab keeps the private copy. The olditem.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
updatedAtstamp; deletes record{id, at}tombstones in a top-leveldeleted[];mergeStashes()(exported fromuseStash.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}andassertConfirmed()throws when no relay answered, so an offline stash write is REFUSED rather than queued:useNostrPublishwould 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;StashPagesurfaces the refusal viaonStashError. Why not per-item events? N items = Nnip44.decrypt()calls per load → N Amber prompts. Schema is inNIP.md→ "Private Stash" → "Multi-device merge". - Offline map tiles (done 2026-09-28):
lib/tileCache.ts(DexieSkateSpotsTilesDB, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)lib/cachedTileLayer.ts(anL.TileLayersubclass 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. Web app shell (2026-10-28):public/push-sw.jsis also the app-shell cache — hashed assets cache-first, navigations network-first with/index.htmlfallback, oldskate-spots-shell-*caches removed. It is registered unconditionally on web bylib/appServiceWorker.ts(skipped on native). The push handlers are unchanged;/api/*and cross-origin model requests are never cached.
- Android navigation + app links (2026-10-28):
@capacitor/appis now a real dependency.DeepLinkHandlerdynamically imports it and handlesappUrlOpen(HTTPS paths,#/HashRouter paths, andorg.skatespots.app://host routes) plus hardwarebackButton(close the top Radix overlay → history back → minimise at the map).scripts/inject-deep-links.mjsadds the verifiedhttps://skate-spots.org+ custom-scheme intent filters to the generated manifest;public/.well-known/assetlinks.jsoncarries the release cert SHA-256B7:52:F2:6A:…:12:0Afor packageorg.skatespots.app. If the signing cert changes, update assetlinks and redeploy; app-link verification happens at install. - Photo redundancy (2026-09-28):
useUploadFilenow uses two Blossom hosts (blossom.primal.net,blossom.band) instead of three. ⚠️BlossomUploader.uploadsigns ONE auth event then doesPromise.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>'], andlib/imageFallback.ts+ImageWithFallbackretry 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.serveon 127.0.0.1:8787; NIP-98 + Pro gate + model allowlist + per-IP throttle), deployed withnpm run deploy:ai(scripts/deploy-ai-proxy.sh) to/home/ubuntu/skatespots-ai, systemd unitskatespots-ai, type-checked byai-proxy/tsconfig.json(typecheck:ai, wired intonpm test);ai-proxy/is eslint-ignored likecvm-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-98utag). Live (2026-09-28):ai.skate-spots.org→ the CVM host (anArecord 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.mjsis now a fallback only — revert withVITE_SASQUATCH_AI_BASE=https://skate-spots.org/api/ai. - Mints directory:
/mintspage lists Cashu mints from the community MintRadar project. MintRadar has no CORS, so it's fetched same-origin at/api/mints/*— prod proxies viapublic/_redirects(/api/mints/* → https://mintradar.org/api/mints/:splat 200), dev viaserver.proxyinvite.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'sserver.proxyalso covers/api/nip05→https://skate-spots.organd/api/ai→https://ai.skate-spots.org(2026-09-28). Without them the dev server answers those production-only endpoints withindex.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 fromhttps://localhost, so/api/mints/knownhits the LOCAL asset server and returnsindex.html→Unexpected token '<' … not valid JSON.useMintDirectorynow useshttps://skate-spots.org/api/mints/knownvia 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 bysasquatchBrain.ts/useSasquatchAiStatus(Capacitor.isNativePlatform() ? 'https://skate-spots.org/api/…' : '/api/…') — follow it for any new/apicall, 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.jsonversion 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-varsby name or blanket rename — shadowed names and typed shorthand destructuring make it unsafe. Fix position-precise. Renaming a component to_Namealso tripsrules-of-hooks— delete it or suppress instead.
- ✅ Lint is clean:
- 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 trackedpackage.cvm.json),*.bak. (cvm-server/source IS tracked — only its locks/backups are junk.) Watch out:git add -Ain the repo root sweeps these in. - Never
git add -Afrom the repo root without checkinggit statusfirst.
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 addressbc1qtunna00uqyx52rr0nvqa059z2gknhrmnqe7h8h. - project Nostr key (signs profile + metadata):
a99eb1d4d546d3a3b40040cc93f77b9a7cd32fab1b693261e03ff1b7ce7400f=npub14x0tr4rd23kn5w6qqsxvj0mhhxnu6vh6kxmfxfs7q0l3kl88gq8sfgwk54. ⚠️ Different from the personal/platform key20e7c953…(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-05owner@skate-spots.org, lud16skatespots@minibits.cash(single-sourced insrc/lib/platform.ts), picture onblossom.angor.io. - In-app display:
/supportershas a Supporters section (curated) fed bysrc/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.FetchProfileMetadataAsyncdoesresult = metadataon 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.SaveAsyncnever persistsRelaysandLoadAsyncnever loads them; adding relays there does nothing. nostr-toolsSimplePool + Node's built-in WebSocket is broken here: it reports a fake✔with no relayOK, and overflows the stack on a failed connect. The tool talks rawwsand waits for the relay's realOK— trust that output, not the old pool behaviour.- NIP-05:
skate-spots@skate-spots.orgis 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 whoseoriginis thenostr://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 runsnpm ifirst (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 unitskatespots-phone-frame.service(systemctl --user status skatespots-phone-frame). ⚠️ Machine-local, deliberately NOT in the repo (kept out of the build/deploy and theeslintHTML gate). It replaced an ad-hocpython3 -m http.server 8097copy in/tmpthat 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.- Bundle analysis (2026-10-09):
ANALYZE=1 npm run buildrunsrollup-plugin-visualizer(already invite.config.ts) and writes machine-readablestats.json— parsenodeParts[].metaUid→nodeMetas[uid].idfor sizes (delete the file after; it is not committed). Findings: the entry chunk is ~1.9 MB minified (~584 kB gzip); its single biggest resident issrc/i18n/en.ts(~352 kB rendered, ~11%), then react-dom, zod, dexie, vaul. The heavy libs are already correctly lazy-chunked (html5-qrcode/zxing,xlsx,leaflet,html2canvas,jspdf,coco-cashu), so don't chase those. The 1.9 MB entry is mostly framework + the English dictionary + shared UI; shrinking it means trimming those, not re-addingmanualChunks(see the TDZ warning above). - Repo weight (2026-10-28): removed 1,295 tracked Gradle build files under
packages/skatespots-nfc/android/build(already gitignored; Gradle recreates them), deleted unusedpublic/images/sticker-design.png, recompressed the large logo/cover/guide assets, and dropped the threaded ORT WASM variants (ort-wasm-threaded.wasm,ort-wasm-simd-threaded.wasm,ort-wasm-threaded.worker.js). The translation worker setsnumThreads = 1, so ORT selectsort-wasm-simd.wasmor the non-SIMDort-wasm.wasm; restore the threaded trio if threads are ever enabled. Tracked tree is ~47 MB (from ~66 MB); the .git history still contains old blobs — only a history rewrite (git filter-repo) would shrink clones, and that would change every commit hash/tag, so it is deliberately not done. - Load/relay performance (2026-10-28): three structural costs were removed.
①
useAuthoris now batched (module queue + one flush per tick): profiles are fetched with one main/fallback/community query per 100 authors instead of 3 queries per card; React Query still owns per-author cache keys. ②MapPageonly queries layers that are actually on — Events/Crews default off and no longer fetch at startup; Shops/Gear/Tours passenabledtoo. ③queryPersister's allow-list now matches the real keys (skate-crews,skate-tour/skate-tours,shop-directory,newsletter-issues, …), so cold starts rehydrate those datasets. The bell is code-split/deferred (1.7.12), images lazy-load (1.7.13), and its crew lookup is member-targeted. Next targets: fold the bell's feeds into one summary query or open-only fetch, paginate the map's 2000-spot payload, a no-vendor service worker for hashed assets/app shell, and the translation model (617 MB M2M100 is the biggest latency cost; use a smaller model or native ONNX).
⚠️ "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 everynpm i, whichdev/test/buildrun, touchespackage-lock.json). The server keeps serving stale?v=hashes → 504 →React.lazyrejects. - Fix:
npm run dev:fresh(clearsnode_modules/.vitethen 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/vitestalone 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
@babel7→8 andesbuild0.25→0.28. Prefernpm ci. If broken, repair each entry'sdependencies/binfrom the installednode_modules/*/package.json, thennpm installto fill only the gaps.
Android release build
npm run android:build→android/app/build/outputs/apk/release/app-release.apk.- Versioning:
package.jsonversionis the single source of truth;scripts/sync-android-version.mjssyncs it toversionName/versionCode(e.g.1.4.5→10405) during the build. Bump onlypackage.json. - Signing: secrets live in the gitignored
keystore.properties;scripts/inject-signing-config.mjsinjects a managed block intoandroid/app/build.gradle(runs inandroid:init/android:sync/android:build). The authoritative keystore is/home/sovereignsk8/skatespots-release.jks(aliasskatespots). An older repo-root copy had a wrong password and was retired to~/.backups/keystore/. android/is generated and gitignored; recreate withnpm run android:init.- Preflight:
cd android && ./gradlew :app:signingReportconfirms the signing config resolves.
Version bump + release notes
- Bump
package.jsonversion(Zapstore rejects an already-published version). - Update the top section of
release-notes.txt— start it directly with the notes, then---before older versions. ⚠️ Do NOT begin the file with avX.Y.Zline: Zapstore already shows the version, so the heading rendered as1.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
manualChunkswithout a boot test. Rollup's default splitting is cycle-safe. scripts/smoke-test.mjsboots the builtdist/in headless Chromium and fails if#rootdoesn'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.shruns 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.mjsmanually before shipping.
Deploy
- Web (auto): a systemd user timer (
skatespots-deploy.timer) runsscripts/auto-deploy.shevery ~3 min. Whenorigin/mainchanges it builds and runsnetlify 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 (matchesAGENTS.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>(0disables); deploy on demand withscripts/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 withscripts/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).
- ⚠️ 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
- 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 pushhangs at "signing event (git state) with remote signer") and the deploy timer skipping whileorigin/mainhad 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-pushruns a bounded 45s realgit 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, orngit account login --interactive).--notify= desktop notification (deduped by an actionable-problem signature).--quiet= silent when healthy. Test against a fake state dir withSKATESPOTS_STATE_DIR=…. - ⚠️
git push --dry-rundoes 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 withscripts/install-ops-health-timer.sh; logsjournalctl --user -u skatespots-ops-health.
- Flags:
- Web (manual):
npm run buildproduces the webdist/(with_headers/_redirects); deploy by draggingdist/to the Netlify site's Deploys tab. - Zapstore (Android), from the repo root:
SIGN_WITH='bunker://...' zsp publish zapstore.yamlzsp publish --wizardis broken for this app (the wizard can't parse a nostrhub webrepository— it supports only GitHub/GitLab/Gitea/F-Droid/local). Always passzapstore.yaml.- No NIP-07 browser extension is installed, so
SIGN_WITH=browserfails. Usebunker://…(Amber) ornsec1…. zapstore.yamlsetsrelease_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.13and 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.yamlzspreads the version from the built APK (synced frompackage.jsonbyandroid:build) — bump onlypackage.json. Publish needs the signer awake (open Amber in the foreground).SIGN_WITH=browserfails here (no NIP-07 extension). ⚠️zsp publishprompts interactively (signer/confirm) — with no TTY it dies withprompt failed: … /dev/tty. Always pass--quiet(auto-yes, no spinners) and, for a dry run,--offline(signs, uploads nothing; events to stdout). ⚠️ The env file~/.config/skatespots/zsp.env(600, outside the repo) holdsSIGN_WITH— as of 2026-10-12 it was an empty template (SIGN_WITH=with no value), so publishing requires filling it (or exporting SIGN_WITH in the shell). ⚠️ Do notsourcethat file blindly: abunker://<pk>?relay=…&secret=…value contains&, which backgrounds the assignment (SIGN_WITHends up unset). Extract it safely instead:SIGN_WITH="$(sed -n 's/^SIGN_WITH=//p' ~/.config/skatespots/zsp.env)"; export SIGN_WITH. Complete non-interactive publish:SIGN_WITH="$(sed -n 's/^SIGN_WITH=//p' ~/.config/skatespots/zsp.env)"; export SIGN_WITH; zsp publish --quiet zapstore.yaml✅ Published 1.6.1–1.7.1 on 2026-10-12/27 — use the protected~/.config/skatespots/zsp.env(mode 600) viascripts/release.sh; the old inlineSIGN_WITH=<nsec1…>path still works but should not be used. Verify the signer is the app identity first (nak key public→npub1yrnuj…/ hex20e7c953…). Use~/go/bin/zsp(v0.4.11) — thezsponPATH(~/.local/bin/zsp) is older (v0.4.10).zspwarns that a nsec in env is insecure (preferbunker://); the relay event to check isnak req -k 30063 -d org.skatespots.app@<version> wss://relay.zapstore.dev.- 1.7.1 (2026-10-27): Hindi + Catalan/Basque/Galician (24 dictionaries /
25 codes), regional flag SVGs + English/Portuguese variants, single-world
map clamp with horizontal wrap to the Pacific, menu hardening; APK sha256
846925af…, versionCode 10701, released withscripts/release.sh. - 1.7.2 (2026-10-27): Basque translation repaired (Marian tokenizer IDs +
EOS + one-time cache eviction), Cordn private-chat polish (auto-check,
3-step onboarding, status/retry, new-device setup, member roles, send
states, invite QR/deep link, dismissible banner), coordinator announce
list trimmed to the community relay. APK sha256
a25ad33c…, versionCode 10702. - 1.7.3 (2026-10-27): native invite QR uses the public
https://skate-spots.orgURL instead of Capacitor'shttps://localhost. APK sha2560448a1c7…, versionCode 10703. - 1.7.4 (2026-10-27): app-wide SkateSpots Community Cordn room
(
/#/community) with an always-on auto-approving bot, admin fallback and external Cordn link; menu audit fixes (mobile language sub-view, crew-tab truncation, touch targets); Cordn share link. APK sha256d93b9cc4…, versionCode 10704. Web forced live at the same commit. - 1.7.5 (2026-10-27): auth dialogs are owned by
Layoutand open at z-2200 (above the z-2100 burger sheet); opening login/signup from the sheet closes it first. Welcome/Log In/Create Account switch without stacking dialogs; the desktop welcome prompt no longer races an in-progress sign-in. Language selection returns to the menu. APK sha256bdb25384…, versionCode 10705. Web forced live. - 1.7.6 (2026-10-28): auth is app-wide via
AuthDialogContext(Layout owns the dialogs), so signup survives the conditional unmount of any page-cardLoginArea; Esc/keyboard close wired explicitly; language search resets on close; trip-itinerary URLs sanitized. APK sha256d8cf3e4a…, versionCode 10706. Web forced live. - 1.7.7 (2026-10-28): native private-chat tab shows an unread dot and a
founder request dot; the crew panel is force-mounted (hidden) so the
passive nsec/bunker poll can keep those fresh. Pending join requests are
remembered per account+room for 30 days and resume auto-check after a
reload. Background signing is gated to nsec/bunker only (Amber/extension
users are not prompted behind their back); quiet request polling never
toggles the composer. APK sha256
009dcf41…, versionCode 10707. Web forced live. - 1.7.8 (2026-10-28): two-device QR handoff (new device publishes a key
package; an existing member scans and adds it without a join request),
MLS member removal, founder link to Edit Crew for admin roles, message
copy/reply, and a live Cordn benchmark. Dependency overrides pin
protobufjs@7.6.3andsharp@0.35.5;npm auditis now 0 critical, 1 high (write-only xlsx), 9 dev-only moderate. APK sha256960052b8…, versionCode 10708. Web forced live. - 1.7.9 (2026-10-28): weekly newsletter automation (generated drafts,
rotating roasts, current tech wires, Blossom cover from the Drop #006
foundation), admin Weekly draft ready banner + Load draft/Preview composer,
Monday 08:00 draft timer (auto-publish opt-in), and static/live issue
dedupe. APK sha256
96b01ec8…, versionCode 10709. Web forced live atd33f65e. First draft: issue #7 (kind 30078,d=skatespots:newsletter:draft) ready for review; not auto-published. - 1.7.10 (2026-10-28): the map footer links are permanently back (the
hide toggle is gone); inline founder promote/demote in the Private chat
member row; encrypted MLS message reactions, delete-for-everyone and read
receipts; the minute Cordn join watcher (NIP-17 DMs + encrypted bell
cards); and the browser translation-matrix runner. First matrix evidence:
en↔es ~54s, en↔ca ~21s, Basque downloads >25min — a real M2M100-WASM
performance problem. APK sha256
9b48e47c…, versionCode 10710. Web forced live at775d1f9. The watcher timer is installed and runs every minute; its key is82ce71cf…, state in~/.local/state/skatespots-cordn-watcher. Low-end/APK translation verification is still open. - 1.7.11 (2026-10-28): performance pass — batched author profiles (one
relay round-trip per 100 authors, not 3 per card), map layers gated by the
actual toggles (Events/Crews no longer fetch at startup), and the public
query persistence allow-list aligned with real keys (crews, tours, shops,
newsletter). No analytics/vendor/server shortcut. APK sha256
a5d99ab9…, versionCode 10711. Web forced live atc903a51. - 1.7.12 (2026-10-28): the notification bell is code-split into
NotificationBellLazy(idle-mounted or loaded on tap) so its five data sources no longer compete with the map/route queries on first paint. The bell button is a lightweight placeholder until then. APK sha25634195aa3…, versionCode 10712. Web forced live ate75a50c. - 1.7.13 (2026-10-28):
ImageWithFallbackdefaults toloading=lazy/decoding=async;SpotCardtoo. The bell now usesuseCrewsByMember(#pquery) instead of the full 200-crew directory; the full query only runs when an admin has an incoming join request. APK sha256591e5779…, versionCode 10713. Web forced live at72d9cd7. - 1.7.14 (2026-10-28): repository slim-down — removed the 1,295 tracked
Gradle build files, deleted unused
sticker-design.png, recompressed large logo/cover/guide images, and removed the unused threaded ORT WASM (worker is single-threaded; SIMD + non-SIMD remain). README rewritten. Tracked tree ~47 MB;git gcpack ~37.6 MB. APK sha2565478df1e…, versionCode 10714. Web forced live at752d104. No feature removed. - 1.7.15 (2026-10-28): Amber-friendly Cordn join notices (encrypted bell
notices are only decrypted when the bell is opened; passive signers still
preload) and a refreshed Zapstore listing (screenshots, current feature
copy, wider NIP list, publisher pubkey guard). APK sha256
7061f5c2…, versionCode 10715. Web forced live atc0e8938. - 1.7.16 (2026-10-28): Zapstore listing media refresh — fresh map/spot
screenshots and the community room instead of the empty marketplace image;
screenshots compressed. No app code change; version bump only to publish
the listing metadata. APK sha256
7e152574…, versionCode 10716. Web forced live at2be49ef. - 1.7.17 (2026-10-28): Android hardware Back (close overlay → history →
minimise), verified
https://skate-spots.orgApp Links + custom scheme via@capacitor/appandpublic/.well-known/assetlinks.json; web app-shell service worker inpush-sw.js(static cache + network-first navigation). APK sha25636f20525…, versionCode 10717. Web forced live at93e1f5e. ⚠️ Device Back/App Link verification still needs a real phone. - 1.7.18 (2026-10-28): Amber prompt-budget card on
/signers(explains which actions prompt, group-DM per-recipient cost, Cordn coordinator requests, and when to use a bunker/local key); service-worker and assetlinks cache headers tightened. APK sha2567a2a7cce…, versionCode 10718. Web forced live at0a13f21. - 1.7.19 (2026-10-28): global command palette (
Cmd/Ctrl+Kor header search) across spots, shops, crews, events, gear, skaters and pages; recent searches are local; datasets are queried only while the palette is open. Browser Back/Forward scroll restoration; Events/Crews map-layer choices persist. APK sha256e8e38ba8…, versionCode 10719. Web forced live at959999d. - 1.7.20 (2026-10-28): NIP-17 group DMs get the same unread dot /
tinted card as 1:1 DMs and mark read when opened (
GroupConversationPageusesgetGroupId+useDMRead). APK sha2561eecf9f4…, versionCode 10720. Web forced live atb7f3fd7. - 1.7.21 (2026-10-28): envelope-only DM unread in the bell. A new
useDmUnreadSummarycounts gift-wrap ids not seen locally (no decrypt, no Amber prompt), excludes this device's own outbound wraps (lib/dmOwnWraps.ts), and shows a Messages row/link with a badge; opening Messages acknowledges. 1:1 + group unread dots clear on read. APK sha2568d658e5b…, versionCode 10721. Web forced live at9baa1bd. - 1.7.22 (2026-10-28): map remembers center/zoom
(
skatespots:map-view), in addition to Events/Crews layer persistence; first-run checklist includes the Community room. APK sha256614b0387…, versionCode 10722. Web forced live atc163666. - 1.7.23 (2026-10-29): first-run tip made frictionless — only optional
Near Me + Got it; no Add Spot/Crew/Wallet/Community CTAs and no crew query.
Inline Mute added to spot/event/tour/gear card menus
(
components/MuteMenuItem.tsx, NIP-51 mute list); report + author hidden notice already existed. APK sha256d49837e7…, versionCode 10723. Web forced live atd729f4369. - 1.7.24 (2026-10-29): Gear Swap saved searches
(
skatespots:gear-saved-searches, apply/delete), quick offer-note templates (marketplaceExtrasUi), and a collapsible buyer/seller safety guide linking/marketplace-guidelines. APK sha2563ddee2f7…, versionCode 10724. Web forced live atca1e5a2. - 1.7.25 (2026-10-29): event detail shows the viewer's IANA timezone
under the local time, and the Gear Swap load-error card has a real Retry.
APK sha256
b528e500…, versionCode 10725. Web forced live atfc7060a. - 1.7.26 (2026-10-29): per-thread mute for 1:1 and group DMs
(
hooks/useDmMute.ts, device-local, header toggle + inbox unread suppression), card Unmute to undo a NIP-51 mute, event Pass shortcut (/card?type=event&event=<id>) for organisers and RSVP'd users, 200% text header truncation, and one quiet Find a crew link in the first-run tip. APK sha2567948f2a6…, versionCode 10726. Web forced live at444597b. - 1.7.27 (2026-10-29): mobile map Local Chat icon button got an
accessible name; first-run close/Find a crew controls got a visible
keyboard focus ring; the bell's encrypted-message row is now
“Messages & crew chats” (crew chat is NIP-17 group envelopes, so it
is included in that single envelope-only count — muted threads still
cannot be subtracted without decrypting). APK sha256
1b3c7379…, versionCode 10727. Web forced live atd6f8345.
- 1.7.1 (2026-10-27): Hindi + Catalan/Basque/Galician (24 dictionaries /
25 codes), regional flag SVGs + English/Portuguese variants, single-world
map clamp with horizontal wrap to the Pacific, menu hardening; APK sha256
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_NSECfor the new events,FROM_NSEConly 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 datacan delete originals via the logged-in (Amber) signer. scripts/archive-relay.mjssnapshots 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 withSKATESPOTS_ARCHIVE_DIR), keeps the last 12, and writesmanifest-*.json+latest.json(counts by kind, author count, sha256, byte size).- ⚠️ The legacy hosted relay
goskatespots.nostr1.comis PUBLIC 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=skateboardingfor events + listings,t=skatespotscollections,t=skatespotsnewsnewsletters,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
querySynccan resolve early on a large page (lost ~150 of 3.3k profiles). Paginate withuntiluntil a page adds nothing. scripts/restore-relay.mjs <archive.jsonl.gz> --dry-runre-verifies every signature;--to wss://relayrepublishes. There is no default target — restoring is deliberate, and the mistake to avoid is publishing a snapshot back to the live relay. Usescripts/drill-relay.mjsas the throwaway target.- Offsite:
scripts/backup-relay.shruns the archive, thenrestic backup+forget --pruneif~/.config/skatespots/restic.envexists (template:scripts/restic.env.example). Configured (2026-09-28): the CVM host over SFTP, reposftp:skatespots-cvm:/home/ubuntu/skatespots-backups/relay, via an~/.ssh/configaliasskatespots-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 generatedRESTIC_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.envandbackup-relay.shpushes 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 {} +.
- ⚠️ Don't run restic under a restrictive
- Restore drill (proven 2026-09-28):
node scripts/drill-relay.mjs [port]starts a throwaway in-memory NIP-01 relay (ws, no persistence); thennode 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.shcopiesscripts/systemd/skatespots-archive.{service,timer}into~/.config/systemd/userand 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. - Dependency updates + vulnerability scan (2026-10-12):
scripts/update-deps.shrunsnpm outdated+npm audit(dated report in~/.local/state/skatespots-deps/), applies non-breaking updates only (npm update+npm audit fix, never--force), then gates onnpm test+smoke-test.mjs: green → commit + push (the deploy timer ships it); red → restores the previous lockfile. Self-gates to one real run per 20 days (DEPS_INTERVAL_DAYS), skips on a dirty tree, and bounds the push (timeout 180) since the git remote signs via the remote signer. Timer:scripts/install-deps-timer.sh→skatespots-deps.timer(daily tick). Flags:--scan-only,--no-push,--force. Logs:journalctl --user -u skatespots-deps. osv-scanner added (2026-10-26): the scan block now also runsosv-scanner scan source -L package-lock.json(binary at~/go/bin/osv-scanner, installed withgo install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest; override withOSV_BIN), pre- and post-update, report-only. ⚠️ It immediately found 107 known vulnerabilities (5 critical, 53 high) thatnpm auditwas NOT surfacing — mostly dev-onlyvite/vitest(patchable within-range) plusxlsx@0.18.5(two high; no fix published) andzod(patch). Dev-dep CVEs don't ship to users;xlsxis lazy-loaded for exports. Run--forceto apply the fixable ones (gated on tests). ⚠️--forceon 2026-10-26 failed the gate and auto-reverted: the blindnpm updatecut OSV findings 107 → 13 but produced duplicate transitive copies (@tanstack/query-corenested underreact-query-persist-client;@nostrify/nostrify/@nostrify/typesnested under@nostrify/react) and a TypeScript bump (TS 5.9 typed-array errors instashBackup/usePushNotifications), sotscfailed. The remaining fixes are mostly major (vitest 4, react-router 7, zod 4) plusxlsx@0.18.5with no fix (we only write XLSX for export, so the parse-side CVEs likely don't apply — verify before replacing). Lesson: do NOT rely on a blanketnpm update; update in curated batches (pintypescript, addoverrides/npm dedupefor the duplicated packages, keep majors as their own migrations) and let the timer gate each. The job now also runsnpm dedupeafternpm update(to stop the duplicate transitive copies that broke type identity). ✅ Curated batch landed (2026-10-26): pinnedtypescript5.8.3 exact, updatedvite6.4.5,vitest3.2.7,@unhead/react/unhead2.1.17,yaml2.9.1, thennpm dedupe—npm testgreen and OSV 107 → 59 findings. Remaining are transitive/major (vitest 4, valibot 1, uuid 11, zod 4) or xlsx (no fix — and it's write-only: onlybook_new/aoa_to_sheet/writeFile, noread, so the parse CVEs don't apply).zapstore.yamllicense wascustom(the listing showed "custom"); nowAGPL-3.0-only(re-published in 1.6.8, verified on the 32267 tag). On failure the job restores the lockfile and schedules a retry in ~3 days (a failed run no longer burns the whole 16-day window); every notification now includes the OSV total ("N packages affected by M known vulnerabilities"). Final security scan (2026-10-26): researched the app-relevant advisories and fixed them — zod 3.25 → 4.6.7 (onlyAppProviderimports zod; no zod-3-isms, zero TS changes) plus @nostrify/nostrify 0.50.5 / @nostrify/react 0.2.31 / @nostrify/types 0.36.9 (0.50 moved to zod 4), and the safenpm audit fixtransitives (dompurify 3.4.17, lodash 4.18.1, @modelcontextprotocol/sdk 1.32.2). Then react-router-dom 7.18.4 (v6 API surface only — zero code changes; smoke covers all routes) and vitest 5.0.5 (dev-only; setup import switched to@testing-library/jest-dom/vitestfor the new expect types). npm audit: 35 → 8 (0 critical, 1 high, 7 moderate). Remaining: xlsx (no fix; we only write XLSX — parse CVEs don't apply) and build-only tailwind/postcss/capacitor-cli/uuid. APK manifest is minimal (INTERNET + coarse/fine location);network_security_configpermits cleartext loopback only (Citrine); CSP is strong (default-src 'none', nounsafe-eval,object-src 'none',frame-ancestors 'none'); no tracked secrets; the onlydangerouslySetInnerHTMLis shadcn's chart CSS-vars (static, not user input). Menu/a11y pass (same date): addedaria-labels to the language trigger and the header settings icon (they had onlytitle/a flag); the desktop nav'smax-w-[1700px]+overflow-x-auto scrollbar-hide+whitespace-nowrapkeeps the trailing More reachable, the mobile bottom nav + burger append the full catalog, dropdowns have max-height + internal scroll, and interactive targets meet the 44px rule. No other menu bugs found. Automation hardening (2026-10-27): the dependency interval is now 16 days (DEPS_INTERVAL_DAYS=16inscripts/systemd/skatespots-deps.serviceand the script default), plus a separate weekly scan-only timer (skatespots-deps-scan.timer, Mon 06:00) that reports npm/OSV findings without touching the lockfile. The deps service hasTimeoutStartSec=30min(the 90s default can kill a legitimate update/test run) and both maintenance scripts resolve the newest nvm Node instead of hard-codingv22.23.1.@tanstack/query-coreis pinned with an npm override to$@tanstack/react-query, sonpm updatecannot create a second copy — the Oct 26 failure was duplicatequery-corebreaking tsc. Failed runs now keeptest.log/smoke.log+ the attempted package files in~/.local/state/skatespots-deps/failures/(8 kept, rotated) and alast-failuremarker; reports keep 12. Releases are one command viascripts/release.sh(notes check →npm test→ bump → Android build →zsp publish→ relay verification → commit/push + tag), reading the 0600~/.config/skatespots/zsp.envby default; pass--deployto also force the web deploy.scripts/ops-health.shnow reports the dep timers, next-due, latest report/OSV andlast-failure. The deploy timer hasTimeoutStartSec=20min, all deploy/deps units are tracked inscripts/systemd/, and.nvmrcpins Node 22. A 2026-10-27 scan-only run reports 3 packages / 4 OSV findings (0 critical;xlsxis write-only and the rest is dev build tooling). - Pre-push + pre-deploy gate:
scripts/preflight.shruns the app + server typechecks → ESLint → i18n → vitest. It is installed as.githooks/pre-pushviascripts/install-hooks.sh, andauto-deploy.shruns it before every Netlify deploy; a failure recordspreflight-failedandops-health.shflags it. Skip one push withSKIP_PREFLIGHT=1(orgit push --no-verify). The dependency and release scripts setSKIP_PREFLIGHT=1because they already run the fullnpm testthemselves. - Patch-only dependency fallback: if the blanket
npm updatefails, the script restores the lock and retries the patch-level outdated packages one at a time, keeping those that pass the fast gate and skipping the rest; a finalnpm test+ boot smoke gates the result. If both the full and fallback paths fail, it restores the original tree and schedules the ~3-day retry.
Ops reports + admin alerts (2026-10-27)
scripts/ops-report.shprints a plain-English daily report (app version, website state, dependency/security state, weekly scan).--sendDMs it to the two admins fromsrc/lib/admins.ts(foundernpub1yrnuj…, second adminnpub1e2ay…) via NIP-17 (scripts/send-ops-report.ts).- Delivery mirrors the app: discover each admin's kind-10050 inbox, fall back to
NIP-65 write relays, always include
wss://relay.skate-spots.org. One gift wrap per recipient; the sender gets the copy.OPS_DRY_RUN=1prints recipients/relays without publishing. scripts/send-ops-alert.sh "…"sends a short alert; wired intoauto-deploy.sh(preflight/smoke failure),update-deps.sh(full+fallback failure) andops-health.sh --notify(deduped by problem signature).- Push: every admin copy (including the sender's) carries the app's
["skatespots","push"]marker, so the push service wakes the phone with the app closed. A push test was sent 2026-10-27 (wraps39fbbd4e…/c88c9f55…). - Scan noise: the weekly scan-only DM is sent only when the OSV summary changes; unchanged scans are logged, not messaged.
- Deploy alerts:
auto-deploy.shalerts on a Netlify failure and on a post-deployhttps://skate-spots.org/health-check failure (deploy-failed/health-failedshow in ops-health and the daily report). skatespots-ops-report.timerruns daily at 07:00 (Persistent); install withscripts/install-ops-report-timer.sh. A test report was DMed to both admins on 2026-10-27 (kind 1059 on the community relay for each).~/.config/skatespots/zsp.env(mode 600) now actually holdsSIGN_WITH(the template was empty);scripts/release.sh, the alert sender and the ops report read it and never print it. Old signer copies were removed from~/.bash_history; clear any open shell's in-memory history withhistory -c; history -wif it predates this.
Message translation — on-device, all languages (2026-10-27)
- Detector (all 24):
src/lib/detectLanguage.ts— tinyld (MIT, lazy-loaded, local) + script rules (Devanagari→hi, Thai→th, kana→ja, Hangul→ko, Han→zh-CN/TW, Greek→el) + a weighted Catalan/Galician/Basque/ Spanish/Portuguese rule for the short/close cases tinyld confuses. Callers may pass a conversation/contact language asprior. No network/logs/storage. 28/28 sample tests pass, including one assertingfetchis never called. - Why not Lingua: Lingua (Apache-2.0) compiles to a 99 MB WASM with 24 languages — rejected as a detector. tinyld + the rule is small, MIT, and covers all 24 (Galician comes from the rule; Lingua has no Galician).
- Privacy invariant: message text must never leave the device. No Chrome Translator, no server, no AI proxy/OpenRouter, no third-party API. Model files are public bytes; only the model is downloaded, never the message.
- All-language runtime (M2M100 + Basque Opus):
Xenova/m2m100_418M(MIT, 617 MB q8) covers 23 app languages (en/es/ca/gl/hi/th/ja/pt/fr/de/it/nl/pl/ru/ko/zh/uk/tr/vi/id/sv/cs/ro/el). Basque is not in M2M100, soXenova/opus-mt-eu-en+en-eu(Apache-2.0, converted q8; ~374 MB each) pivot through English.- Hosted at
https://relay.skate-spots.org/models/…(Caddyhandle_path /models/*→/srv/models, CORS*; backup/etc/caddy/Caddyfile.bak-…). - Verified from the host: en↔es, ca↔gl, hi↔en, and eu↔en after the
2026-10-27 fix. The Basque packs are converted and hosted (ONNX q8).
Tested in Node against the same hosted tokenizer/ONNX bytes; outputs match
the original Python model. Two root causes: the ONNX IR 9→8 mismatch,
and the converted
tokenizer.jsonusing SentencePiece IDs (special tokens 0/1/2) instead of the Marian model'svocab.jsonIDs — so</s>was never recognized and generation looped. Rebuilt both tokenizer.json files in model-vocab order with source-spm scores, special tokens at the model IDs, and an EOS post-processor; also set Metaspaceadd_prefix_spaceso the first word gets▁. Backups are on the model host. - Cache migration:
src/lib/translationModelCache.tsdeletes only the old cachedopus-mt-{eu-en,en-eu}tokenizer.jsonentries on first Basque use (the 390 MB ONNX stays cached). Unit-tested; the worker calls it before loading a Basque translator. - Runtime:
@xenova/transformers2.17.2 + ONNX Runtime 1.14 WASM self-hosted inpublic/ort/(37 MB, copied fromonnxruntime-web), loaded from../ort/; workersrc/workers/translate.worker.ts. M2M100 is used directly; Basque pivots eu→en→target / source→en→eu. - Hard guard:
src/lib/translationGuard.tspermits only model GET/HEAD (no query string) and same-origin/ort/assets. Message text only travels viapostMessageto the local worker.translation.test.ts+translationGuard.test.ts(9 tests) provefetchis never called with a sentinel message and every other request is blocked. - UI:
TranslateMessagein DMs (ConversationPage) and crew chat (CrewChat);TranslationSettingsin Settings → Appearance (auto-translate off by default). Translations are memory-only; reload/logout clears them + terminates the worker. First use downloads the multilingual pack with progress. 12translateUikeys in en/es + all 24drift.ts(check 100%); the old “pilot” note is unused. - SpotChat was deliberately skipped (owner request).
- Provenance: M2M100 is MIT open weights (Meta origin) but runs entirely locally — nothing goes to Meta. Basque Opus is Apache-2.0. No Chrome, server translation, AI proxy or third-party API.
- Conversion: Docker Python 3.11 + torch 2.4.1 + transformers 4.46.3 +
optimum 1.24; dynamic q8;
tokenizer.jsongenerated from the SentencePiece models (needs protobuf +sentencepiece_model_pb2.py).
- Browser matrix runner (2026-10-28):
scripts/translation-matrix.mjs(npm run translation:matrix) servesdistand drives the builttranslate.workerin headless Chromium;MATRIX_LANGUAGES=es,calimits it. It writes/tmp/opencode/translation-matrix.jsonand a per-language partial. First runs on this box: en↔es ~54s, en↔ca ~21s (model download included for es); Basque downloads did not finish in a 25-minute run. This is a real performance finding, not just missing test coverage: the 617 MB M2M100 single-threaded WASM is too slow for low-end phones. Low-end/APK verification still needs a device (and likely a smaller model or native ONNX). - Still to do: verify all 24 pairs in the browser/APK and measure speed/memory on low-end phones. 1.7.2 and 1.7.3 are released (the phone needs the Zapstore update; the web deploy timer batches for 24h unless forced). No SpotChat.
- Keyboard caveat: a malicious OS keyboard or rooted device is outside the
app’s control. Warn/recommend a trusted offline keyboard, and avoid the OS
keyboard for secrets (custom secure input / QR / bunker/Amber,
FLAG_SECURE, no autofill/suggestions). Translation’s guarantee is app-level: no plaintext egress.
Sasquatch translation + privacy (2026-10-27)
- The Sasquatch UI, guide and FAQ strings (
sasquatchUi/sasquatchChat/sasquatchGuide) are translated in all 24 locales (i18n 100%). - The AI reply language directive was only
es/th/ja;SasquatchGuide.tsxnow hasSASQUATCH_LANGUAGESfor all 24 (skate trick names and core vocabulary stay in English), so replies follow the app language for every locale. - Privacy differs: the Sasquatch AI is not on-device — it calls the CVM/ AI proxy (OpenRouter). Its questions/replies leave the device. Only the translation is local/no-egress; do not conflate the two.
Community relay — self-hosted Newlay (2026-10-12)
The community relay moved off the hosted relay.tools instance
(wss://goskatespots.nostr1.com) to our own Newlay at
wss://relay.skate-spots.org. Host: the same VPS as ai./push.
(185.18.221.2, vm-1705.lnvps.cloud, ssh key ~/.ssh/skatespots-new).
- Stack: Newlay (
artifacts.relay.tools/opensauce/newlay, MIT) in Docker (Compose), bound to127.0.0.1:7777only; Caddy terminates TLS (arelay.skate-spots.orgsite block was appended to/etc/caddy/Caddyfile, auto Let's Encrypt) and reverse-proxies it. Do NOT add nginx — Caddy is already the proxy for ai/push/relay. - Repo on the box:
~/newlay; config~/newlay/deploy/docker/newlay.toml(bind-mounted read-only into the container).sudo docker compose up -d. - Config choices:
[tier.anon]open read+write (no WoT gate, no spider);created_at_msecs_ago = 315360000000(10 years — the default 365-day window silently dropped an old spot on restore); publishing/filter limits raised from the tight defaults. The relay is keyless (no NIP-43/NIP-29 membership). ⚠️max_limitwas 1000 but the map asksuseSkateSpots(2000)— the REQ exceeded the cap and the map looked empty. Raised anonmax_limitto 5000 (2026-10-26). Also raised[ip_gate] max_connections_per_ip16 → 64 andconnect_attempts_per_min120 → 300: our own test queries hit 429 on the WebSocket upgrade (easy to mistake for an empty relay — always check the handshake status, not just the event count). NIP-11'sauth_for_read: trueis cosmetic; unauthenticated reads work. Config backup:~/newlay/deploy/docker/newlay.toml.bak-*; restart withsudo docker compose up -d --force-recreatefrom~/newlay. ⚠️ Content can silently miss the canonical relay: the app publishes togetWriteRelays()and resolves on the first relay that accepts, so a rate-limited community-relay write can leave a spot only on ditto/damus (this happened to a Chengdu spot, 2026-10-26 — republished the signed event withnak req … | nak event wss://relay.skate-spots.org). Parity check (kind 30818): our relay had 56, ditto+damus union 26; the only missing item was a junk test spot. Re-check parity after any relay/IP-gate change. - Data: restored from the archive — 115/115 events, 17 authors, every kind
matching. LMDB + (unused) Blossom live in the Docker volume
newlay_newlay-data. - Admins / NIP-86 (2026-10-12):
[tier.admin](can_admin = true) plus two[[assignment]]rows grant the app's two admins (20e7c953…founder,caba497f…) the NIP-86 management RPC thatsrc/lib/relayAdmin.tscalls (banevent,listeventsneedingmoderation,getrelaystatus, …). Verified with a throwaway admin key: admin → 200 (method list), non-admin → 403, no auth → 401. The app's admin hard-delete is now real on the relay (falls back to NIP-32 label hiding only if NIP-86 is unreachable). Manage admins by editing~/newlay/deploy/docker/newlay.tomlanddocker compose up -d --force-recreate. - systemd: the host runs
skatespots-relay.service(adocker composewrapper,enable --now; tracked atscripts/systemd/skatespots-relay.service). Restart withsudo systemctl restart skatespots-relay. The container also carriesrestart: unless-stopped. - Monitoring (2026-10-12):
skatespots-health.{service,timer}on the host runscripts/host/relay-health.shevery 5 min — checks relay/ai/push/cvm/caddy + the public endpoints + disk/memory, restarts the relay if its localhost NIP-11 dies, and posts to ntfy.sh when the problem set changes (deduped; also a "recovered" message). The topic is in/home/ubuntu/skatespots-health.env(mode 600, not tracked) — subscribe to it in the ntfy app. Units tracked atscripts/systemd/skatespots-health.{service,timer}. - Media / Blossom (2026-10-12): the relay also serves Newlay's Blossom at
blossom.skate-spots.org(a Caddy site block → the same127.0.0.1:7777; blobs stored at/var/lib/newlay/data/blobs, INSIDE the data volume so they survive container recreation).[blossom] enabled = true,upload_requires_membership = false(no membership model),public_base_urlset. The app uploads there first (useUploadFileUPLOAD_SERVERS) and keepsblossom.primal.net+blossom.bandas mirrors;BLOSSOM_MIRROR_HOSTS(lib/imageFallback.ts) includes it so reads fall back across all three. Verified end-to-end (signed PUT /upload → 201, GET / → 200). To fully self-host media later, drop the two public hosts from both lists. - Cordn coordinator (2026-10-12): the relay also runs Newlay's embedded Cordn
coordinator for crew MLS chat (
[cordn] enabled,mode = "embedded",data_dir = /var/lib/newlay/data/cordninside the volume,relay_url = ws://127.0.0.1:7777,announce_interval_secs = 900(15 min so the health probe reads Connected, not "stale" — the example's 86400s/24h default was too long). Its identity iscordn.key(mounted read-only) — pubkey02816a05…— ⚠️ the file must be owned by the container'snewlayuser (uid 10001,chown 10001:10001), else the relay crashes on/etc/newlay/cordn.key: Permission denied. BYO coordinator stays client-side. In the app:SKATESPOTS_COORDINATOR(src/lib/coordinator.ts) is the provided default; Settings → Coordinator shows a "Use SkateSpots coordinator" block and CrewForm prefills it for new crews. Disclosure copy lives insocialUi.coordinator.ourDefault*+ the existingprivacyNote(coordinators see ciphertext + metadata, never contents). ⚠️CrewCoordinatorCardwas rewritten (2026-10-12, seeCORDN_UX_AUDIT.md): it used to badge "Private chat · On" while the MLS tab said "not created yet" (a contradiction) and led with the external apps. It now shows two explicit rows — Chat (NIP-17, always on) and Private chat (MLS; On / Set up / Join / Checking fromnativeGroupExists) — with the in-app chat as the primary CTA (?tab=mls), and "Cordn (web)" / "Copy for Staircase" demoted under an "Open in another app" label. Props are{ crew, isFounder, canManage, nativeGroupExists }. Also fixed a status-badge bug:STATUS_METAlabels inCoordinatorSettingsCardwere missing thesocialUi.prefix and rendered raw keys (e.g.coordinator.statusStale). Verified (2026-10-12): the reference@contextvm/sdkclient (encrypted ContextVM transport) connects to our coordinator and lists its tools (kp_*,welcome_*,join_request_*,msg_*) — a real Cordn client works. The cordn.net web app does not complete the handshake (connects + NIP-42-auths, then sends no request; upstream issue drafted). Crew Cordn group link (Level 1, 2026-10-12): a crew may carry['cordn', <cordn1-ref-or-url>](SkateCrew.cordnGroup);CrewFormhas a "Cordn group" field andCrewCoordinatorCardshows an "Open crew chat" + "Copy for Staircase" row. In-app Cordn/MLS (Level 2) remains the big client build. - Cutover (2026-10-12):
SKATE_RELAY(src/lib/types.ts) → the new relay; the default pools, the DM/CVM/zap relay arrays, thecvm-server+push-servicedefaults, and the archive default all point at the new relay. - Legacy relay retired (2026-10-12): verified the old relay held the same 115
events as the new one (0 diff) before changing anything, so nothing is lost.
wss://goskatespots.nostr1.comwas then removed from the pools, the servers and the box env. ⚠️ Old native clients (a pre-update APK) that still point at the old relay will lose skate content once the relay.tools subscription is cancelled — cancel only after the Zapstore update has propagated. Historical newsletter text still names the old relay (left as-is on purpose). - Stored relay lists are reconciled (2026-10-12):
RETIRED_RELAY_HOSTSinlib/relays.ts(goskatespots.nostr1.com, deadwotr.relatr.xyz) are pruned at runtime (resolveRelays), on config load (AppProvider) and on NIP-65 sync (NostrSync); and the community relay (SKATE_RELAY) is added to the NIP-65 list when missing (unlessuseSkateRelayis off).NostrSyncalso republishes the corrected kind-10002 once for non-Amber signers (silent) so other clients see the skate scene; Amber users keep it local (no unprompted popup) and republish from Settings → Relays. - ⚠️
relay.skate-spots.orgcan show a stale NXDOMAIN in a local resolver cache right after creation (our dev box did). Verify withcurl --resolve relay.skate-spots.org:443:185.18.221.2 …or from another host. - Backups: the weekly
skatespots-archive.timerstill runsscripts/backup-relay.sh, but it now archives through an SSH tunnel to the relay host's127.0.0.1:7777(overridable viaSKATESPOTS_RELAY_SSH; falls back to the public URL if the tunnel can't be established), so it never depends on public DNS resolving from the dev box. Local snapshots stay in~/.backups/relay(12 kept) and restic pushes to the host's repo (~/.config/skatespots/restic.env). Two locations today (relay host + dev box); a third would need a new offsite destination.
Git / ngit publishing
- Push:
git push origin main(handled bygit-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"). Plainngit account loginerrors; the--interactiveflag is required. - The NIP-65 write list contains a dead relay (
wss://wotr.relatr.xyz), which makesngit repo editfail unless run with--repo-relay-only. Local git confignostr.repo-relay-only trueis set as a workaround. Fully fix by removing that relay in the app (Settings → Relays). Done in defaults 2026-09-28: removed fromApp.tsxdefaultConfig,NostrSyncNEW_USER_RELAYS,NostrProviderDEFAULT_RELAYS,TestApp, and the CVMDEPLOY.mdexample. ⚠️ Existing accounts keep it in their stored config until they remove it themselves (no migration applied). git pushmay print a benigncannot lock ref ... incorrect old value providedretry warning; verify withgit 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 comprehensiveSKILL.mdfile 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,WalletModalfor Lightning payments
/src/hooks/: Custom hooks including:useNostr: Core Nostr protocol integrationuseAuthor: Fetch user profile data by pubkeyuseCurrentUser: Get currently logged-in useruseNostrPublish: Publish events to NostruseUploadFile: Upload files via Blossom serversuseAppContext: Access global app configurationuseTheme: Theme managementuseToast: Toast notificationsuseLocalStorage: Persistent local storageuseLoggedInAccounts: Manage multiple accountsuseLoginActions: Authentication actionsuseIsMobile: Responsive design helperuseZaps: Lightning zap functionality with payment processinguseWallet: Unified wallet detection (WebLN + NWC)useNWC: Nostr Wallet Connect connection managementuseNWCContext: Access NWC context provider
/src/pages/: ~50 route pages (MapPage is the homepage; seeAppRouter.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 schemaAGENTS.md(this file): the only cross-session memory — keep it currentUX_AUDIT.md(pre-refactor, partly stale),UX_AUDIT_2026-10-09.md(app-wide + marketplace UI/UX audit), andMARKETPLACE_FLOW_AUDIT.md(the deep trade/escrow/offers/error audit, with severity-ranked findings and a fix order),GROUP_MESSAGING.md,ESCROW.md: feature decision/backlog docs/src/test/: Testing utilities including TestApp component/public/: Static assetsApp.tsx: Main app component with provider setup (CRITICAL: this file is already configured withQueryClientProvider,NostrProvider,UnheadProviderand 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:
- Edit
AGENTS.mddirectly when you learn something important or when a described detail has changed. - Changes take effect in the next session — so record now, not "later".
- 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.
License — AGPL-3.0-only (2026-10-09)
The repo is licensed AGPL-3.0-only: the full text is in /LICENSE,
copied verbatim and unedited from the teambtcmap/btcmap-android LICENSE
(the advice: never hand-edit a licence — hand-edited ones are why lawyers
exist), and "license": "AGPL-3.0-only" in package.json. No "or later" grant
is added, so v3 only. Chosen because the trust work reviews AGPL-licensed
reference implementations (GrapeRank / Trusted Assertions) and we reimplement
rather than copy; AGPL keeps that clean and matches the ecosystem. The
practical effect: derivatives and network deployments must stay open under the
same terms ("network use is distribution"); as the sole copyright holder the
owner may still license his own extra work differently.
⚠️ AGPL section 13 means a hosted modified version must offer its source to
network users — the public repo (nostr git + Netlify) plus the License page's
"View source" link satisfy that; keep it that way. Do not add a dependency whose
licence is incompatible with AGPL. The LicensePage copy still says "open"
without naming AGPL — update it when convenient. (Also: GitHub/license scanners
recognise LICENSE, so the file is deliberately not LICENSE.md.)
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
thalongside en/es on the same framework; core plumbing unchanged. Translations live insrc/i18n/th/as fragments composed inindex.ts(so parallel batches don't edit one giant file). - ✅ COMPLETE (2026-10-07):
this 100% — 7,889/7,889 keys. The partial-locale rule below still applies to any future keys. - Partial by design: a missing
thkey falls back to English.check:i18nis now multi-way — en/es stay key-for-key identical (unchanged),th(andja) may be partial and are reported as coverage %; ath/jakey absent fromenfails. - Font:
@fontsource/kanit(Kanit), lazy-loaded per locale (lib/localeFonts.ts) 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 inth(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.
Catalan, Basque, Galician, Hindi + pt-PT — 24 dictionaries, 25 codes, all 100% (2026-10-26)
- Added ca (Català), eu (Euskara), gl (Galego), hi (हिन्दी) → 24
dictionaries; pt-PT is a variant that shares the
ptdictionary (25 selectable codes). - Registered in
Language+LANGUAGE_LOADERS,detectLanguage(ca/eu/gl/hi checked beforees;pt-BR→pt,pt-PT→pt-PT), theLanguageTogglelist, anddateFnsI18n(date-fnsca/eu/gl/hi/pt, codesca-ES/eu-ES/gl-ES/hi-IN/pt-BR/pt-PT). - Flags are real SVGs in
public/flags/forca,eu,glandgb-eng(there are no Unicode region flags for Catalonia/Basque/Galicia, and England is not the UK flag).LanguageTogglerenders a path as<img>and an emoji as text. English now uses the England flag (not the UK), Portuguese has Brasil 🇧🇷 (pt) + Portugal 🇵🇹 (pt-PT), and Hindi uses 🇮🇳. pt-PTshares theptbase and applies a European drift (src/i18n/pt-PT/drift.ts: Salvar→Guardar, Senha→Palavra-passe, Ônibus→Autocarro, etc.). A full European-Portuguese review is a follow-up.- Map world clamp:
SpotMapusesworldCopyJump: trueandapplyWorldMinZoom()(smallest integer zoom whose 256·2^z world covers the viewport). That stops a second full copy of the continents and keeps the map covering the surface (no grey bands), while still letting you drag across the antimeridian to Hawaii/Pacific. On a wide desktop the whole globe cannot also fit without distortion; the clamp prioritises coverage. - Full dictionaries in
src/i18n/{ca,eu,gl,hi}/— 7 namespace batches each (8,703 keys), composed inindex.ts; placeholders verified, only 2–4% identical strings (brand/protocol names).check-i18n.mjsincludes them inextraLocalesand reports 100%. - Catalan/Galician/Portuguese are Latin; Hindi is Devanagari, so
@fontsource/noto-sans-devanagari(400/500/700) is lazy-loaded forhivialib/localeFonts.ts(same pattern as CJK) and the CSS stack includes'Noto Sans Devanagari'beforesystem-ui. - Menu hardening across all locales: the language dropdown is
w-72withtruncate+ a fixed 24px flag slot (SVG and emoji align); the settings language grid is 1 column on phones / 2 onsm+; the header More menu isw-64; the header nav scrolls (overflow-x-auto scrollbar-hide) so long labels never push the logo/controls; the bottom nav truncates its labels.:lang(hi/th/ja/ko/zh)removesuppercase/tracking-wide, because Devanagari/Thai/CJK labels look broken when letter-spaced. - Translation sources were split by namespace into
/tmp/opencode/i18n-src/batch1..7.json(regenerate fromen.tsif re-translating). When adding new UI strings, add them toen/esand all 24 dictionaries (25 codes) (batch file ordrift.ts).
i18n drift closed — all 20 locales 100% (2026-10-13)
- Every non-en/es locale had fallen to 97% (245 keys each —
trustUi,labOptions,cordnUi,socialUi,walletUi,marketTerms, …). Backfilled 245 × 20 = 4,900 strings intosrc/i18n/<lang>/drift.ts(a pure fragment). - ⚠️
drift.tsMUST be merged, not shallow-spread. Its keys live in namespaces that already exist in thebatchNfragments, so...driftlast would replace whole namespaces (e.g.socialUi688 → 24 keys, tanking coverage). Every localeindex.tsnow doesdeepMerge({ ...batchN }, drift)via the sharedsrc/i18n/deepMerge.ts(deepMerge(base, extra)— recursive, string-dictionary only). Add future late keys todrift.tsand they merge the same way.check-i18n.mjsshould stay at 100% for all 20. - (Backfilled by parallel translation subagents; brand/protocol/trick names kept
English,
{{placeholders}}preserved — all verified.)
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.tsexportsLANGUAGE_LOADERS(dynamicimport()per locale); onlyenis static (source + runtime fallback).LanguageProviderloads 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). (2026-10-12) CJK fonts are lazy-loaded per locale (
lib/localeFonts.ts, wired incontexts/LanguageContext.tsx) — they're ~16 MB of font payload that non-CJK users no longer download at boot. check-i18n.mjsnow reports all 12; new locales are partial (coverage %) until complete. Adding a locale = register inlanguage.ts(Languageunion +LANGUAGE_LOADERS),LanguageContext.detectLanguage,LanguageToggle,dateFnsI18n, and scaffoldsrc/i18n/<lang>/{index,batch1..8}.ts.- Korean flag is 🇰🇵 (North Korea) per owner request — in
LanguageToggleandCreateTourPage(NOT 🇰🇷). - ⚠️ One writer per file. Large namespaces (
settingsUi1223,walletUi1050,labUi+socialUi1464,places+market+labOptions1689) 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 truncatedko/batch3,ru/batch3,ru/batch5twice).
Japanese (日本語) — fourth locale (2026-10-07)
- Added
jaalongside en/es/th on the same framework. Translations live insrc/i18n/ja/as fragments composed inindex.ts. - ✅ COMPLETE (2026-10-07):
jais 100% — 7,889/7,889 keys, same asth. Both partial-locale rules below still hold for any future keys. - Partial by design (same as
th): a missingjakey falls back to English.check-i18n.mjsreportsjacoverage and fails if ajakey is absent fromen. - Font:
@fontsource/noto-sans-jp(Noto Sans JP), lazy-loaded per locale (lib/localeFonts.ts) 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 inja(detectLanguage). - ⚠️
LanguageToggleinside a Radix Sheet/Dialog must use theinlineprop. The dropdown is a RadixDropdownMenuthat portals to<body>, and an open Sheet/Dialog setspointer-events: noneon<body>— so the portaled menu inherits it and its items become unclickable.LanguageToggle inlinerenders plain buttons (no portal) instead. The mobile nav sheet usesinline; 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-costcalculator (170 keys)becomeRolesUi—/become/*role forms +CraftProfileForm(123 keys; added*Keyfields tobecomeRoles.tsx)weatherUi— weather/AQI/skateability labels (60 keys;weather.tsgained*Keyfields, consumers updated: SpotDetailPage, SpotMap, TripItineraryCard, the Sasquatch weather tool) Weather UI pass (2026-10-12): added a map legend (components/WeatherLegend.tsx; colours mirrorcalculateSkateability), a freshness + manual refresh line and an error/retry state on the spot weather card, sunrise/sunset (weather.tsfetches Open-Meteo dailysunrise,sunset), and the score's reasons now render as chips instead of a bullet-joined run-on. Popuptext-gray-500→gray-300for contrast. Units stay Celsius / km/h (deliberate — no unit toggle). The forecast is now 6 days (FORECAST_DAYSinlib/weather.tsis the single source for the fetch and the labels; the legend colours live inlib/skateabilityLevels.ts). Labels usespotUi.forecastDays/mapUi.forecastDayswith a{{count}}param (the other locales fall back to English until translated).skaterLabelsUi(21) — skater stance/style/level + sponsor tiers intypes.tsnotifyUi(28) — OS notification titles/bodies (OsNotificationBridge)legacyMiscUi(~160) — scattered component/page literals, imagealt/aria-label, and the generated export-document text (PDF/XLSX/PNG/Markdown/clipboard) ⚠️ Still English by design: thesrc/i18n/glossary.tstrick glossary, imagealt/aria-label(mostly brands or shadcn internals), example placeholders (you@example.com,npub1…), and generated export-document bodies (PDF/XLSX). Re-scan withgrep -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 useperl -CSDon 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 inlocalStorageapp-language. - First-visit auto-detect:
detectLanguage()inLanguageContext.tsxstarts Spanish-device visitors ines(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:
SasquatchGuideappends alanguageDirectiveto the system prompt (both the first streaming pass and the tool-result synthesis pass) whenlanguage === 'es'. - Keep
en.tsandes.tskey-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 intonpm testas part of the chain) fails the build if any literalt('…')key is missing fromenORes, or ifen/esdrift. It also scans data tables for keys declared askey:/titleKey:/labelKey:/badgeKey:(e.g.src/lib/coachOptions.ts), because those are looked up dynamically viat(option.key)and a plain literal scan misses them — that gap shipped rawlabUi.…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 intoen/esviabatch3/index.ts(export default { ...en, ...batch3En }), so parallel translation passes never edit the bigen.ts/es.tsliterals — 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_KEYSstatic maps,routeCache.formatAge).sasquatchUi.*—SasquatchGuide,SasquatchProDialog,SasquatchActionCard,SasquatchAiStatusCard, plussasquatchPro.tsPRO_TIERS.labelKey/badgeKeyanduseSasquatchActions.summarizeDraft(d, t). LLM system prompts and chat bodies stay English (the model is language-directed separately).collectionUi.*/bootUi.*—AddToCollectionDialogplaceholders,SplashScreenstatus 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.tsholds the shared*_KEYSvalue→key maps (single source; the five duplicatedEVENT_TYPE_KEYScopies now import it).statusTimeAgo()andformatPrice(price, type, t?)are locale-aware now.- Dates & relative times are localized (2026-10-02):
src/lib/dateFnsI18n.tsre-exportsdate-fnswithformatDistanceToNow/…Strict/formatDistance/formatdefaulting to the active locale, plusdateLocaleCode()for nativetoLocale{Date,Time}String.LanguageProvidercallssetDateLocale(language)during render so the first paint is in-language. Import from@/lib/dateFnsI18n, notdate-fns. Number.toLocaleString()calls are deliberately left on the runtime default.
- Batch 4 (2026-10-04) — done. Fragment layer
src/i18n/batch4/(spread asbatch4En/batch4Esalongside batch 3).labOptions.*(430 keys) — every Lab*_LABELS/*_DESCRIPTIONSmap insrc/lib/types.ts(coach/filmer/editor/photo/builder/artist/graffiti/tattoo/ trainer/musician/judge/organizer/barber) +TOUR_SKILL_LEVELS; 51 shared key maps appended tosrc/lib/labelKeys.ts. Consumers:becomeRoles.tsx,CraftProfileForm(SkillSectiongained akeyMap),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-tierSTATIC_FAQ(English matcher →sasquatchChat.faq.*key) and local-result/fallback chat bodies. Live AI is already language-directed; prompts stay English.feesUi.*(57 keys) —usePurchaseProreceipt prose/errors andPlatformFeePaymentfee/duration tiers (LabProUpgrade/FeaturedEventUpgradewere alreadylabUi/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.tstrick glossary.TagCategorynames/descriptions andMUSICIAN_ACT_LABELShave 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 — thecheck:i18nguard caught all 738 usages. Always runnode scripts/check-i18n.mjs(it's innpm test) after wiring a file. - ⚠️ When wiring a file, keep
en.ts/es.tskey-for-key in sync and runnode scripts/check-i18n.mjsbefore 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.
2026-10-28 audit tail. npm audit is now 10 advisories (0 critical, 1
high, 9 moderate). The remaining high is xlsx, which is write-only in
this app (parse CVEs do not apply). Overrides pin protobufjs@7.6.3 (the
critical ONNX model-parsing path) and sharp@0.35.5 (Node-only transformers
dependency). The moderates are dev-only (@capacitor/cli/xcode/uuid, Tailwind
typography/postcss). AI-generated itinerary links go through sanitizeUrl;
every target="_blank" has rel=noopener noreferrer; no javascript:/raw user
HTML paths found. Auth is app-wide via AuthDialogContext so signup survives
card/page unmounts, and all internal nav links match a route.
Deferred (documented, NOT fixed).
H1— PARTIAL:claim-fromnow stores the original coordinate (30818:pubkey:d, not an ambiguous bare pubkey),SkateSpotparsesclaimedFrom/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.tsverifies a provider-authored NIP-57 receipt (cached provider lookup) and the tour/event/Lab list hooks rank only receipt-backedfeatureditems; 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 inflatepaymentVerified.H13— FIXED: the offer seller is always the listing author (derived from the event pubkey), never a self-declaredroletag.H14— FIXED: after an NWCpay()timeout,useNWCpollslookupInvoicefor 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-listingsquery key memoized; Pro grant requires thellabel; verify-card RSVP maps accepted/tentative; expired listings can't be bought by URL; batch spot photo/status hooks take the relay suffix +querySkateContent; offlinepublishingrows 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-3313direct 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/
1111on the event). Relays must NOT enforce NIP-59'spguard (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_sdkis 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 crewcoordinatortag, 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.
Related: TRUST_SCORE.md collects the trust-scoring review and plan. Its
first slice has landed: src/lib/nip85.ts (kind-10040 designation +
kind-30382 assertion parsing; absence = unknown, never distrust),
src/hooks/useTrustAssertions.ts (batch, signature + author verified),
src/lib/trustLadder.ts (buckets Verified/Unknown/Flagged;
FLAGGED_REPORTERS_THRESHOLD 2), src/hooks/useTrustBucket.ts, and
components/TrustBadge.tsx now renders the three buckets (Unknown hidden by
default, Flagged always shown, and Verified suppressed for accounts you already
follow). TrustBadge is used by the map/tour/event cards, marketplace card,
listing detail and coach detail, so those surfaces get it for free. The provider
read is batched (one query per viewer for the union of on-screen subjects,
in useTrustAssertions.ts), and useUserReports + components/TrustFlaggedNotice.tsx
add a flagged-counterparty notice (reporters grouped by the viewer's network,
Report/Dismiss) wired into the listing seller and coach detail. Legibility
pass: the badge tooltip attributes the source (your network / community
provider / your provider) and links to a public /trust explainer;
Settings → Trust (?section=trust, components/TrustSettingsCard.tsx) has a
show/hide-marks toggle (useTrustDisplay, Flagged is never hidden) and shows the
active provider + relay (useTrustProvider), and lets the viewer publish a
kind-10040 provider designation (useSetTrustProvider: custom npub/hex +
wss:// relay, or clear to fall back to the community provider). The local
(non-provider) ladder is computed once per viewer by useViewerGraph and
shared, so TrustBadge no longer fires a per-card useTrustScore graph query.
The local ladder has a confidence gate: useViewerGraph counts distinct
follows per friend-of-a-friend and deriveLocalScore only verifies at
FOF_MIN_RATERS (2) or more, so a lone endorsement is not trust; provider
assertions are unaffected.
Mute landed: useMuteList + useMuteUser (NIP-51 kind-10000, optimistic via
lib/localMutes.ts) feed useAdminModeration's new mutedPubkeys, so the
shared filterAdminHidden drops muted authors across every list; the flagged
notice has a Mute action. Alerts landed: useNetworkAlerts + /alerts
(pages/NetworkAlertsPage.tsx) list accounts you follow that trusted people
reported/muted, with Ignore/Unfollow/Mute/Report; Settings → Trust links to it
with a count (ignored set in skatespots:trust:alerts-ignored). Deferred: our
own GrapeRank-style scorer — see the dated note below. Check
TRUST_SCORE.md before touching useTrustScore / useRelatrScore /
trustFilter.
Own GrapeRank-style scorer — DEFERRED (2026-10-09). Do NOT build the
cvm-server per-observer scorer yet. The full confidence model is provider-side
and already consumed via NIP-85, and the client fallback now has a local
confidence gate (useViewerGraph, FOF_MIN_RATERS). Why hold: personalized
kind-30382 needs a per-observer signing key (server key custody or per-user
delegation — a trust/key-management burden, not a checkbox); an 8-hop traversal is
heavy without a graph store; plus a provider identity, the publish/delete
lifecycle, deploy to the external host and monitoring. With ~zero users the ROI is
negative. Cheaper path if a controlled score is ever wanted: a
house-perspective scorer (one key, one observer, computed on a schedule,
publishes kind-30382) — Settings → Trust's provider choice consumes it with no
client change. Revisit when: there are users, no trustworthy provider exists,
or there are skate-specific signals (vouches, trade attestations) worth baking
into a score only we can compute. The client is already provider-ready
(useTrustAssertions + kind-10040 choice), so this decision costs nothing today.
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:
-
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.
-
Prioritize Existing NIPs: Always prefer extending or using existing NIPs over creating custom kinds, even if they require minor compromises in functionality.
-
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
-
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
-
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
-
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:
-
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
-
Use Single-Letter Tags for Categories:
- Relays only index single-letter tags for efficient querying
- Use
ttags for categorization, not custom multi-letter tags - Multiple
ttags allow items to belong to multiple categories
-
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
- Design tags to enable efficient relay-level filtering with
-
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"] -
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:
- Combine kinds: Use
kinds: [1, 6, 16]instead of separate queries - Use multiple filters: When you need different tag filters, use multiple filter objects in a single query
- Adjust limits: When combining queries, increase the limit appropriately
- Filter in JavaScript: Separate event types after receiving results rather than making multiple requests
- 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 metadatansec1: private keys - Secret keys (should never be displayed publicly)note1: event IDs - Just the 32-byte event ID (hex), no additional metadatanevent1: event pointers - Event ID plus optional relay hints and author pubkeynprofile1: profile pointers - Public key plus optional relay hints and petnamenaddr1: 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-10nevent1: Contains event ID plus optional relay hints and author pubkey - for any event kind- Use
note1for simple references to text notes and threads - Use
nevent1when 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
npub1for simple user references - Use
nprofile1when 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:
- Root-Level Route: The route
/:nip19inAppRouter.tsxcatches all NIP-19 identifiers - Automatic Decoding: The
NIP19Pagecomponent automatically decodes the identifier usingnip19.decode() - Type-Specific Sections: Different sections are rendered based on the identifier type:
npub1/nprofile1: Profile section with placeholder for profile viewnote1: Note section with placeholder for kind:1 text note viewnevent1: Event section with placeholder for any event type viewnaddr1: Addressable event section with placeholder for articles, marketplace items, etc.
- Error Handling: Invalid, vacant, or unsupported identifiers show 404 NotFound page
- 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:
- The NIP19Page boilerplate is already created - populate sections with specific functionality
- The route is already configured in
AppRouter.tsx - Error handling is built-in - all edge cases show appropriate 404 responses
- 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
- Always decode NIP-19 identifiers before using them in queries
- Use the appropriate identifier type based on your needs:
- Use
note1for kind:1 text notes specifically - Use
nevent1when including relay hints or for non-kind:1 events - Use
naddr1for addressable events (always includes author pubkey for security)
- Use
- Handle different identifier types appropriately:
npub1/nprofile1: Display user profilesnote1: Display kind:1 text notes specificallynevent1: Display any event with optional relay contextnaddr1: Display addressable events (articles, marketplace items, etc.)
- Security considerations: Always use
naddr1for addressable events instead of just thedtag value, asnaddr1contains the author pubkey needed to create secure filters - 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:
- Create your page component in
/src/pages/ - Add it with
lazyWithRetry(not barelazy) 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
anytype: 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:
-
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) - For Inter Variable:
-
Import the font in
src/main.tsx:import '@fontsource-variable/<font-name>'; -
Update Tailwind configuration in
tailwind.config.ts:export default { theme: { extend: { fontFamily: { sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'], }, }, }, }
Recommended Font Choices by Use Case
- Modern/Clean: Inter Variable, Outfit Variable, or Manrope
- Professional/Corporate: Roboto, Open Sans, or Source Sans Pro
- Creative/Artistic: Poppins, Nunito, or Comfortaa
- Technical/Code: JetBrains Mono, Fira Code, or Source Code Pro (for monospace)
Theme System
The project includes a complete light/dark theme system using CSS custom properties. The theme can be controlled via:
useThemehook for programmatic theme switching- CSS custom properties defined in
src/index.css - Automatic dark mode support with
.darkclass
Color Scheme Implementation
When users specify color schemes:
- Update CSS custom properties in
src/index.css(both:rootand.darkselectors) - 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:
- The user explicitly asks for tests to be written in their message
- The user describes a specific bug in plain language and requests tests to help diagnose it
- 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:
TestAppcomponent 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:
- Type Checking (Required): Ensure TypeScript compilation succeeds
- Building/Compilation (Required): Verify the project builds successfully
- Linting (Recommended): Check code style and catch potential issues
- Tests (If Available): Run existing test suite
- 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.