Commit graph

257 commits

Author SHA1 Message Date
SkateSpots
8338ec2b33 docs(agents): record the offline write queue wiring 2026-09-29 22:07:20 +07:00
SkateSpots
3f51740f6a fix(map): stop the offline banner sticking; feat(trades): notify trade confirmations
Offline banner: it keyed off a single relay probe (SKATE_RELAY) while the map
reads from several relays, so it reported 'showing saved spots' while fresh data
loaded fine - and its retry only refetched spots, never re-probed, so it appeared
impossible to clear. Now the saved-spots banner is driven by deviceOffline
(!useOnlineStatus) only, is dismissable (re-armed when the connection returns),
and both retries call refetchSpots() AND relay.recheck(). The relay probe still
gates the 'nothing at all' card.

Trade confirmations: usePendingTradeConfirmations now returns createdAt/eventId,
so 'someone confirmed your trade' becomes a trade-confirm notification - the
missing final step. Bell card + OS title cover it; confirming back is what makes
the trade mutual and builds both reputations.
2026-09-29 21:06:04 +07:00
SkateSpots
d9a906ab6e feat(market): first-class barter in offers (item tag)
Listings already supported price_type=trade, and attestations carry method=trade,
so swaps counted for reputation. Offers, though, could only name a sats amount.

- 30403 messages may now carry an optional 'item' tag: what the sender is
  offering in kind. It combines with amount (e.g. 2,000 sats + an 8.25 deck) or
  stands alone for a pure swap.
- OfferDialog: 'Or trading something' field + validation (amount, item or note).
- ListingOffersPanel: the thread shows the offered item, and the counter UI takes
  an optional item alongside the amount.
- Bell/OS text reads it ('offered 2,000 SATS + an 8.25 deck').
- NIP.md documents the item tag; AGENTS notes barter is now first-class.
2026-09-29 18:50:40 +07:00
SkateSpots
0b7371263c feat(bell): dedicated Trades tab
The bell had All/Social/News/Crews; trade steps were only inside All. Added a
5th tab (Handshake, orange accent) listing offers, counter-offers, outcomes and
trade confirmations, with its own count. All still shows them first.
2026-09-29 18:42:24 +07:00
SkateSpots
a6aeb2feb0 feat(market): counter-offer negotiations + trade notifications
Offers were one-way (buyer proposes, seller accepts/declines) and fixed-slot
addressable events, so the buyer could never counter back and the haggling left
no trace. Now it's a thread.

- useMarketplaceOffers: every message gets a UNIQUE d (append-only) with a
  'role' tag (offer = buyer / response = seller), 'e' = the message it answers,
  and status. New counterOffer() plus respondToOffer(previous, status). Parsing
  still accepts the legacy fixed-slot d form when the role tag is absent.
- useListingOffers returns OfferThread[] (oldest first) with  = whose
  turn it is; useIncomingOffers returns the threads awaiting me (across listings).
- ListingOffersPanel: shows the volley, and accept/decline/counter (inline amount)
  for whichever side's turn it is, with withdraw on your own open offer. Counter
  notifications are nudged over DM too.
- New useTradeNotifications: incoming offers, counters and 'confirm our trade'
  requests, derived from events already addressed to the user.
- Bell: trade items render first in All and count toward the badge (vs seenAt).
- OS notifications: new 'trades' category in notificationPrefs + a trades source
  in OsNotificationBridge.
- NIP.md updated for the thread model (legacy form documented).
2026-09-29 18:33:11 +07:00
SkateSpots
e632285702 ui: compact route summary, map-first route dialog, clearer crews filter toggle
- Directions: the Distance/Time/Arrive tiles are now a compact one-line strip
  (StatTile removed), and the dialog's DOM order matches its visual order
  (header, map, vehicle row, From row, details, pinned Start) with redundant
  order-* so a dev CSS-timing hiccup can't scramble it.
- Crews: the filter expand toggle now has a divider and always shows its
  More/Less label, so it no longer reads as another crew category.
2026-09-29 17:38:01 +07:00
SkateSpots
1e0902f6b9 fix(ui): unblock the zoom control, tidy Go Pro, and make the route map bigger
Three phone issues from a screenshot review:

- Map: the floating toolbar sat on top of Leaflet's top-left zoom control.
  index.css now offsets .leaflet-top.leaflet-left by 4.25rem under max-width
  767px, scoped to the map page via a new  class
  (desktop toolbar is in flow, so no offset). Verified at 390x844.
- Sasquatch: the cramped red 8px 'Go Pro' chip is now a proper lime pill with
  the bolt, matching the panel.
- Directions: the map was 38vh and buried under the mode row. It's now 50vh
  (440px on larger screens; 60vh/62vh while following) and sits directly under
  the header, with the mode/vehicle row and 'From' row moved BELOW it. Done with
  flex order-* utilities rather than moving JSX; Start bar stays pinned.
2026-09-29 17:30:25 +07:00
SkateSpots
620ee646d2 feat(routing): phone navigation — voice turns, wake lock, arrival, bigger follow map
Follow mode was a live dot on a north-up map you had to keep watching. Now it
guides:

- Voice: device TTS via speechSynthesis (free, offline, no server, no
  permission), preferring an on-device voice in the user's language. Speaks at
  ~150 m and ~40 m before each turn (once per band, per step) and on arrival.
  Unlocked by the Start tap; mute toggle persisted in skatespots:route-voice.
- Wake lock: navigator.wakeLock keeps the screen on while following, released on
  stop/unmount and re-acquired on visibilitychange.
- Arrival: within ~30 m -> "You've arrived", haptic, auto-stop, Done button.
- Haptics: navigator.vibrate on the 150 m / 40 m bands and arrival.
- Layout: while following the map grows to 58vh (60vh sm) and the planning
  step-list steps out of the way; the NEXT banner is bigger.
- Copy updated: no longer claims 'no voice guidance'.

Still absent: auto-reroute if you leave the route. True screen-off navigation
needs a native foreground service (app must stay foregrounded).
2026-09-29 17:16:28 +07:00
SkateSpots
93f195ecf3 docs(agents): record the floating map toolbar layout 2026-09-29 17:05:58 +07:00
SkateSpots
322ec58ada fix(crew): stop overclaiming about public relays in the coordinator copy
The transport treats a crew's wss:// coordinator URL AS A RELAY, and an https://
one falls back to the public wss://relay.contextvm.org. So 'nothing crew-specific
hits a public relay' was false for https coordinators — it put ciphertext +
metadata (crew coord, timing) on a public relay.

- CrewCoordinatorCard: shows the actual relay in use, and states honestly
  whether it's the crew's own or the shared public one.
- CrewForm: the coordinator field now says a wss:// endpoint is the crew's own
  relay and an https:// one routes over the shared public relay.
- AGENTS.md: record the relay-vs-coordinator transport reality so this isn't
  re-derived (and Cordn RPC is not called).
2026-09-29 16:48:17 +07:00
SkateSpots
ae2a1c70b8 feat(coordinator): profile discovery — publish yours, add theirs in one tap
Carrier is the existing kind-0 'cordn' field (app convention, NIP.md) — no new
kind. It now holds a coordinator *pointer* rather than only a URL.

- EditProfileForm: accepts nprofile / cordn.net link / npub / hex / http URL,
  validates via parseCoordinatorInput, normalises a bare pointer to Cordn's
  canonical add-link on save, and adds 'Use my coordinator'. Label -> Coordinator.
- NIP19Page: when the profile's cordn value parses as a pointer, render a
  Coordinator row with a one-tap Add (sets the viewer's coordinator, with toast)
  and an 'Open in Cordn' link; otherwise keep the old plain-link row.
- NIP.md: document the broadened cordn field (pointer formats + one-tap add).
- smoke test: now boots a real profile route too.
2026-09-29 16:17:16 +07:00
SkateSpots
ee380a5c74 feat(crew): accept a phone-run Cordn coordinator in the crew form
Crew-level Cordn chat already existed (lib/cordn, useCordnChat, CrewChat,
CrewCoordinatorCard, the crew 'coordinator' tag). The real gap: the crew form
accepted a URL only, so a coordinator run from Cordn's phone app — which yields
an nprofile, not a URL — could not be entered at all.

- lib/coordinator.ts: parseCrewCoordinatorInput / crewCoordinatorInputValue —
  the same pointer formats as Settings -> Coordinator (nprofile / cordn.net
  link / npub / hex) plus a plain endpoint URL.
- CrewForm: parses the pointer, shows live valid/invalid feedback, sets
  ['coordinator', url, pubkey?] (schema unchanged), and gains a one-tap
  'Use my coordinator' prefill from Settings.
- NIP.md: note the accepted pointer formats on the coordinator tag.
- AGENTS.md: record that crew-level Cordn chat already exists (the lesson:
  grep src before building).
2026-09-29 16:02:09 +07:00
SkateSpots
7b33d544b1 feat(coordinator): Cordn client slice — pick/add a coordinator + run-your-own hand-off
SkateSpots stays a Cordn *client*: it points at a coordinator the user
chooses or runs, and hands phone-hosting off to Cordn's own app. No embedded
coordinator, no default SkateSpots coordinator (deliberate: liability).

- src/lib/coordinator.ts: Cordn's own formats — an nprofile pointer, the
  canonical add-link https://cordn.net/chat/coordinators?c=<nprofile> (from
  Cordn packages/server/src/main.ts), default relay wss://relay.contextvm.org,
  the docker one-liner. Parses nprofile/npub/hex/link; encodes an add-link.
- src/hooks/useCoordinator.ts: device-local store + presence probe. Health is
  NOT Cordn RPC — it queries the coordinator's relays for authors:[pubkey] (its
  ContextVM announcement/heartbeat), so a private/unannounced coordinator
  honestly reads 'Not seen' rather than failing.
- src/components/CoordinatorSettingsCard.tsx + Settings -> Coordinator:
  empty/connected states, add by paste or QR scan, add-link copy + QR, and a
  'Run your own' hand-off (open Cordn, scan its QR, copy the docker command).
- smoke test now boots /settings?section=coordinator.
2026-09-29 15:11:36 +07:00
SkateSpots
5c8f84fca1 docs(agents): Phase 2 background runner is impossible (no WebSocket); hold at Phase 1+1b
Verified against Capacitor v8 docs: Background Runner exposes fetch but not
WebSocket, and Nostr relays are WS-only — so on-device background relay polling
cannot work. Recorded the two real alternatives (push gateway / foreground
service) and the owner decision to hold at the zero-server design.
2026-09-29 14:17:57 +07:00
SkateSpots
d966bca148 feat(notify): scheduled event reminders (Phase 1b) + v1.5.7
An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.

- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
  tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
  are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
  my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
  pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
  useSasquatchPro query into a nudge.

Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
2026-09-29 14:12:55 +07:00
SkateSpots
63dc4bee7d feat(notify): opt-in on-device OS notifications (Phase 1)
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.

- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
  uses @capacitor/local-notifications (lazily imported, so the web bundle never
  pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
  opted in. Per-source silent priming + a seen-id set means history is never
  replayed as a flood, and it stays quiet while the app is visible (the bell
  already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
  no decryption) — so Amber is never prompted just to be told you have a
  message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
  default off, per-category toggles. Settings -> Notifications UI.

Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
2026-09-29 12:59:13 +07:00
SkateSpots
120c76b052 docs(agents): record map pin rendering perf fix (signature guard + batched addLayers) 2026-09-29 12:47:46 +07:00
SkateSpots
359a510c65 fix(dev): proxy /api/nip05 and /api/ai in the Vite dev server
Both are production-only (Netlify Function + the CVM-host proxy), so on
localhost the dev server answered them with index.html and the NIP-05
availability check showed 'Could not check right now' (and dev AI silently fell
back to Pollinations). Proxying them to the real endpoints (as /api/mints
already is) makes dev behave like prod. Verified: the check returns a real
availability result and /api/ai/status returns the live Routstr balance.

Dev-only config — the built output is unchanged, so the deploy guard skips it.
2026-09-28 19:46:03 +07:00
SkateSpots
5f5a954ce6 feat(profile): badges — clickable chips + a SkateSpots member badge
- lib/badges.ts + ProfileBadges: a profile shows (1) the built-in SkateSpots
  badge, derived from a nip05 ending @skate-spots.org (app logo linking to
  https://skate-spots.org/ — no storage, works for existing users), and (2) user
  badges from a kind-0 badges array ({label,url?}) rendered as clickable chips.
- NIP19Page: app badge in the header, user badges in a 'Badges' card (replaces
  the plain Sponsors section).
- EditProfileForm: Sponsors editor becomes a Badges editor (label + optional
  link); saves the badges array and retires the legacy newline sponsors field.
  Existing sponsors are read back as label-only badges, so nothing disappears.
2026-09-28 19:40:19 +07:00
SkateSpots
ee97a1c5ca feat(spots): make reports act, add a publish floor, stop junk looking endorsed
The 'test of secret island airport' case: a no-photo, one-line, reported pin sat
on the map looking legit. Four fixes:

- A) Report has a visible effect: a 'Reported as not a skate spot by N — under
     review' banner on the spot page, for everyone.
- B) Publish floor: a spot needs a photo OR a non-empty description. Photo stays
     optional (per the earlier decision); title + type alone no longer publishes.
- C) Auto-hide from the map when >=3 DISTINCT accounts report it AND it has no
     community signals (no community photo, no status report). The 'no signals'
     clause is the anti-brigade guard; reversible. useSpotReports now exposes
     distinct .
- D) The flattering 'Conditions ... NN/100' block is hidden until the spot is
     confirmed (community or staff) — on an unconfirmed pin it read as the app
     endorsing the spot itself.
2026-09-28 19:32:01 +07:00
SkateSpots
563ce33276 feat(pro): honest personal context, wanted-ads draft, days-left, live USD, copy
- #3 usePersonalContext decrypts the Stash ON DEMAND (unlockStash, called by
  getMyStash) instead of never — 'what's in my stash?' is now correct, while the
  globally-mounted guide still never decrypts on mount. stashRef avoids the
  stale-closure bug.
- #4 new self-contained action draft wtb_gear (NIP-99 listing_type=wtb) — a
  'wanted' gear ad. SYSTEM_PROMPT + tool handler + publish branch.
- #5 panel header shows 'Pro · Nd' days left (title = exact expiry).
- #6 tier picker computes live USD via useExchangeRate; removed the hardcoded
  ~$60k/BTC usd field from PRO_TIERS.
- #7 copy: 'skate status' not 'spot status'; trip planning stated as free.
2026-09-28 19:05:54 +07:00
SkateSpots
124cac74fa fix(pro): stack Pro proofs + owner-grant fallback
#1 Extend now actually extends. useSasquatchPro collected receipts and took the
max of (receipt.created_at + tierDays), so buying again before expiry
under-delivered. It now stacks every valid proof with renewal semantics
(expiry = max(expiry, proofTime) + days), client and server alike.

#2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32
kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely
dependent on the Lightning provider publishing zap receipts. New owner-only
GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook
reads via querySkateContent.

Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and
the Netlify function accept the new payload and a bare receipt (old APKs). Proxy
was deployed first and verified healthy before this commit.
2026-09-28 18:58:25 +07:00
SkateSpots
a8dc1b1ffb chore(relays): drop the dead wotr.relatr.xyz from default relay lists
Removed from App.tsx defaultConfig, NostrSync NEW_USER_RELAYS, NostrProvider
DEFAULT_RELAYS, TestApp, and the CVM DEPLOY.md example. It has been failing for
a while and just adds a dead socket + latency to every default relay set.

Existing accounts keep it in their stored config until they remove it in
Settings -> Relays (no migration applied). APK rebuilt (1.5.5 / 10505) so the
release matches.
2026-09-28 18:13:19 +07:00
SkateSpots
5b63a69a2c chore(backup): support a second offsite repo (restic-2.env)
backup-relay.sh now pushes the archive to restic.env AND, if present,
restic-2.env — so adding an R2/B2 bucket gives true 3-2-1 (local + CVM +
object storage) instead of replacing one offsite with another. Each repo is
sourced in its own subshell so RESTIC_* don't leak between them.

Verified with a throwaway local second repo: both pushes ran, both snapshots
exist. Also documents the umask trap (mode-600 cache dirs break restic).
2026-09-28 14:24:57 +07:00
SkateSpots
08189a71af fix(dm): read history from the user's real inbox relays, not a stale list
All four DM read hooks used config.relayMetadata.dmRelays, which is only set by
the login-time sync. When it is stale/empty the app reads only NIP-65 relays —
where gift wraps almost never are (real case: 94 addressed wraps on nostr.mom,
0-1 on the user's NIP-65 relays), so Messages showed 'No messages yet' with a
full inbox.

- useResolvedDmRelays(): discovers the user's own kind-10050 inbox relays at
  query time (per-relay, 6s each, over read relays + aggregators) and unions
  them in. Used by useConversation/useConversations/useGroupConversations/
  useGroupConversation.
- useConversations now returns { conversations, undecryptable, wraps } and the
  DM page surfaces 'N encrypted messages couldn't be opened', so a decrypt
  failure stops looking identical to an empty inbox. Updated DirectMessagesPage
  and ContactsPage.
2026-09-28 14:13:51 +07:00
SkateSpots
356e4d5c95 feat(ai): route production AI to our own proxy on ai.skate-spots.org
- DNS: A record ai.skate-spots.org -> 185.18.221.2 (Netlify DNS).
- TLS: Caddy on the CVM host, automatic Let's Encrypt (verified live).
- Client: production builds (web + APK) now default to
  https://ai.skate-spots.org/api/ai; dev uses local /api/ai. Override with
  VITE_SASQUATCH_AI_BASE to revert to the Netlify Function, which stays
  deployed as a fallback.
2026-09-28 13:53:46 +07:00
SkateSpots
ec06a63b01 feat(ai-proxy): run the AI proxy on the CVM host, ready to switch over
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.

- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
  allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
  (typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
  restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
  u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
  move it. Fully reversible.

Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
2026-09-28 13:32:05 +07:00
SkateSpots
f46f6a8967 chore(deploy): throttle production deploys to one per 24h
The timer deployed on every site-affecting commit at 15 Netlify credits each
(~68 deploys / ~1,020 credits in a week). auto-deploy.sh now enforces a minimum
gap between successful deploys, default 24h, overridable with
SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds> (0 disables). A throttled tick records
nothing so the change still deploys later; failures retry immediately.

Deploy on demand with: scripts/auto-deploy.sh --force

scripts/docs only — skipped by the timer, so this costs 0 credits.
2026-09-28 13:20:31 +07:00
SkateSpots
c89d32d92e chore(backup): offsite on the CVM host (SFTP) + proven restore drill
- Offsite backup is now live: restic over SFTP to the CVM host via a scoped
  ~/.ssh/config alias (no host/key in tracked files); restic.env (600, outside
  the repo) holds the repo URL + generated password. Verified: first push
  dedupes to ~32 MiB and 'restic check --read-data-subset=10%' is clean.
- scripts/drill-relay.mjs: throwaway in-memory NIP-01 relay (ws) for restore
  drills. Ran the loop end-to-end against the real archive: 114/114 events
  restored, 0 missing.

Scripts/docs only — the deploy timer skips this (no Netlify credits).
2026-09-28 13:16:53 +07:00
SkateSpots
d9ad9e9b29 feat(offline): cache map tiles on-device + photo mirror fallback
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000
tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it
(cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap,
RouteDialog). Chosen over a service worker so it works in the Capacitor
WebView too, where a SW never sees tile requests.

Photos: useUploadFile drops from three Blossom hosts to two and records
['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any
PUTs to every server in parallel, so the old three-host list was triple-
uploading every photo (the 'tries them in order' comment was wrong). Read
side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback
retries them before showing the placeholder - one choke point for ~20 photo
call sites, so a dead host no longer blanks a spot.
2026-09-28 13:01:27 +07:00
SkateSpots
08bc3a8b41 docs(agents): record offline/dead-relay behaviour + nip05 seeds 2026-09-28 12:52:04 +07:00
SkateSpots
c01521ea3f feat(backup): scoped weekly archive of the community relay dataset
scripts/archive-relay.mjs snapshots the app's own content off
goskatespots.nostr1.com to gzipped JSONL of the raw signed events, so a
restore can republish them verbatim to any relay. scripts/restore-relay.mjs
re-verifies signatures and publishes to an explicit --to (no default, to
avoid replaying a snapshot onto the live relay). scripts/backup-relay.sh
wraps it and pushes with restic when a repo is configured; a weekly systemd
user timer runs it.

Scoped on purpose: that relay is PUBLIC and carries general traffic (15.7k
kind-0 profiles, 2k kind-30078 app-data from other clients, 408 foreign
NIP-99 listings), so the archive keeps only the app's own kinds/tags plus
contributor profiles, SkateSpots labels, and reports that reference archived
content. ~114 events / 63 KiB.

Not configured yet: offsite (restic.env) and a full restore drill.
2026-09-28 12:41:50 +07:00
SkateSpots
3c7c8bd87e feat(nip05): free name@skate-spots.org handles, claimed at signup
SkateSpots now runs its own NIP-05 registry, so a new skater gets a human
handle from minute one instead of an npub.

- netlify/functions/nip05.mjs: /.well-known/nostr.json plus
  /api/nip05/{check,claim,release}, backed by Netlify Blobs (first-party,
  no new vendor). Claims are NIP-98 signed and written onlyIfNew, so only
  the owner can claim a name for themselves. Platform names are seeded in
  code and cannot be taken or released.
- src/lib/skateNip05.ts + src/hooks/useSkateNip05.ts +
  src/components/SkateNip05Input.tsx: shared validation, debounced
  availability, and the claim control.
- SignupDialog: a 'SkateSpots name' field, prefilled from the display
  name. Claimed best-effort before the profile publish; a failure never
  blocks signup, and the profile only advertises nip05 once confirmed.
- EditProfileForm: claim a free handle from the NIP-05 field.

Verified on a draft deploy: claim -> resolves -> a second key gets 409 ->
release -> gone. tsc/eslint/101 tests green.
2026-09-28 12:13:34 +07:00
SkateSpots
5b770dc79b contents(app-shop): move the free printables into the 'Sticker packs' entry
The sticker + poster were their own 'Free to print' section, which made the shop
read as two lists. They now live inside the 'Sticker packs' card in the 'In the
works' grid (full-width, FREE badge, compact previews + Print/Save), between
Decks and Shop tools — so a category shows what it already offers.

Verified: no 'Free to print' heading remains, both printables still render, and
the page reads hero -> in the works (5 entries) -> how buying works.
2026-09-28 11:21:03 +07:00
SkateSpots
263ce11073 docs(agents): record the nostrapps.com PR recipe + gallery assets 2026-09-28 11:16:28 +07:00
SkateSpots
9a8056700d contents(about): rework the Credits list
- Remove MKStack and Shakespeare credits (owner request). AGENTS.md updated so a
  future session does not re-add the old 'Vibed with MKStack' instruction.
- 'Nostr' now links to the protocol repo (github.com/nostr-protocol/nostr) in
  both the Credits card and the 'Powered by' footer.
- Add: Cordn (cordn.net), Cashu (cashu.space), NWC (docs.nwc.dev), Amber
  (github.com/greenart7c3/Amber), BTCMap (btcmap.org).

Verified in the browser: no MKStack/Shakespeare text, all five new links present
with the right hrefs, BTCMap spelled correctly.
2026-09-28 10:31:43 +07:00
SkateSpots
3c0a91a379 feat(moderation): report inbox, bulk undo, crews + Lab in the queue
- Report INBOX: /review now defaults to a Reports tab. useRecentReports fetches
  recent NIP-56 (1984) and resolves the referenced event by id (one extra ids:
  query), so ANY report is actionable — content that isn't flagged, comments,
  profiles, anything. reportTargetMeta maps kind -> label + route for the link.
  Actions: Hide / Dismiss report (verify) / Undo / Look at it.
- Bulk undo: when a tab holds >1 verdict, a toolbar offers 'Undo all N' with an
  AlertDialog that warns each undo is its own label publish (one Amber approval
  each). Built for the 13 old hidden labels from Sep 3.
- Crews + Lab tabs: added includeHidden to useCrews/useLabProfiles. Hiding these
  removes a community / a person's listing, so their Hide goes through a
  confirmation (confirmHide/confirmHideNote on QueueList).

Verified in the browser: 8 tabs render; the inbox shows and resolves a real
report (target + reason); the Spots tab shows the bulk-undo toolbar.
tsc/eslint clean, 101 tests, smoke PASS.
2026-09-28 10:20:54 +07:00
SkateSpots
f1ddf5fdf1 feat(quality): review queue for every kind, soft photo gate, empty-map nudge
(a) Review queue covers spots/shops/events/tours/gear: added { includeHidden }
    (admin-tooling only) to useSkateShops/useSkateEvents/useTours/
    useMarketplaceItems; generalised the flags to contentFlags() in spotQuality.ts
    (trust/status/photo signals stay spot-only); one component per kind so only the
    OPEN tab queries. Reports + trust are kind-agnostic; the label mechanism never
    cared about kind.
(b) Photo gate softened: removed the hard block (it punished an honest mapper with
    no photo). Publish without a photo is allowed; the spot stays Unverified, the
    success toast says so, and the queue's 'no photo' flag lets a human look.
(c) Empty-map nudge: MapPage now distinguishes a FILTER empty ('No spots match')
    from a genuinely EMPTY map ('No spots mapped yet — be the first to map your
    park', with Add-spot + Near-me). Note: must key off allLocations, not mapSpots
    (which derives from filteredSpots).

Verified in the browser: the empty-map nudge renders with its CTAs when no spots
load; the review queue renders 15 spot rows and the tabs switch (Gear shows its
own empty state). tsc/eslint clean, 101 tests, smoke PASS.
2026-09-28 09:59:09 +07:00
SkateSpots
e549aeee3a feat(spots): reports reach the admin, duplicates are caught, contributors are told
1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader
   in the app was a count on marketplace cards, so an admin never saw one. Now
   useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and
   the review queue ranks reported spots ABOVE the heuristics with a red
   'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays()
   (community relay + the reporter's), not the default pool — the admin reads
   from the community relay, so reports written only to personal relays would
   never arrive. Verified end-to-end: published a report from Node, saw it
   surface in the queue.

2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are
   listed with distances + links, with 'It's a different spot — publish anyway'.
   Targets the clone-spam failure mode (five near-identical pins).

3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its
   AUTHOR (it used to silently 404 for the person who made it), and
   SpotDetailPage shows an author-only banner for hidden/verified.

tsc/eslint clean, 101 tests, smoke PASS.
2026-09-28 09:45:36 +07:00
SkateSpots
e44f8b81a7 feat(moderation): reversible verdicts (undo) + community photos as a signal
Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
  UI); it keeps the NEWEST verdict per target (created_at, with device-local
  verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
  'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
  {verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
  a hidden spot leaves the list, so a verdict could never be undone. The review
  queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
  un-actioned first.

Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
  queried by #a, so batching is free), counting only photos from people OTHER
  than the mapper. Folded into the 'no community signals' flag.

Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. tsc/eslint clean, 101 tests,
smoke PASS.
2026-09-28 01:06:04 +07:00
SkateSpots
0419c18810 feat(moderation): positive 'verified' verdict + trust/community signals in the review queue
- MODERATION_LABEL_VERIFIED: the positive counterpart to 'hidden'. Both labels
  are fetched in ONE query; useAdminModeration now exposes verifiedCoords/
  verifiedIds/isVerified() with a local mirror (addLocalConfirmed/
  getLocalConfirmed). useAdminVerify() publishes it (shares the publish path
  with useAdminHide).
- Review queue: a human review can now CONFIRM a spot, not just hide it. A
  confirmed spot clears from the queue and gets a 'Verified by SkateSpots' badge
  on the spot page (staff verdict, shown separately from the community badge).
- Two more signals: mapper trust (useBatchTrustScores) and whether any skater has
  filed a status report (useBatchSpotStatus reporterCount) -> 'mapper not in your
  web of trust' / 'no community signals'. The latter only fires alongside another
  flag so a brand-new real spot isn't punished for being new.
- Flag logic moved to src/lib/spotQuality.ts so the page and AdminReviewCard share it.

Verified: the queue lists the airport entry with suspicious name + no photo + no
community signals, shows mapper trust, and offers Confirm/Hide/Not a skate
spot/Look at it.
2026-09-28 00:25:12 +07:00
SkateSpots
a43ff47b28 feat(spots): honest quality signals, photo required, off-topic reports, admin review queue
The 'test of secret island airport' problem: a junk pin wearing an app-endorsed
'Skateability 95/100'.

- Rename the weather score to 'Conditions · weather only' (SpotDetailPage +
  the SpotMap popup). It was never a verdict on the place.
- New Community verified / Unverified badge from signals only OTHER skaters can
  produce: community photos + useSpotStatus reporterCount (kind 2259).
- CreateSpotPage now REQUIRES at least one photo (guard + disabled submit +
  a required hint on the label).
- useNotASpotReport: one-tap NIP-56 (1984) report tagged 'not-a-skate-spot'
  (NIP-56 'other' + our L/l/t taxonomy), wired into the spot's More menu and
  added to the shared ReportDialog reasons.
- useAdminHide: the MISSING WRITE SIDE for admin moderation. useAdminModeration
  always read NIP-32 kind-1985 'hidden' labels but nothing published one, so the
  only working hide was device-local. Now publishes the label (+ optimistic
  local hide). Soft and reversible — the event stays on relays.
- /review (AdminReviewPage, admin-only → 404 otherwise): review queue over
  useSkateSpots with cheap flags (suspicious title, name <=3 chars, no photo, no
  description, no geohash), sorted by flag count, one-tap Hide or Not a skate
  spot. Entry via the admin-only AdminReviewCard in Settings → Advanced.
- /review added to the smoke-test route list.

Verified: the airport spot shows UNVERIFIED with 'Conditions · weather only' and
no 'Skateability'; the review queue lists it with flags + Hide/report actions;
create-spot blocks without a photo.
2026-09-27 23:41:49 +07:00
SkateSpots
0adfbcbd29 feat(app-shop): remove the subscribe band and the honesty note
Per request, the page is now: hero -> in the works -> free to print -> how
buying works. Nothing else changed.

Honesty is still carried by the remaining copy: the COMING SOON badge, the
'IN THE WORKS / not for sale yet' label on the teasers, and the hero's 'sold
locally, on request'. No prices, no buy buttons, no stock list.

Also drops the now-unused NewsletterSubscribeButton/ArrowRight imports, and
updates release-notes + AGENTS.md (they described the removed copy).
2026-09-27 23:23:14 +07:00
SkateSpots
4341653abe feat(app-shop): add free printable sticker + poster (vector, real QR)
- public/images/print/skatespots-sticker.svg (55 x 86 mm) and
  skatespots-poster.svg (A3) — the free artwork to print and stick up. Physical
  size is baked into the SVG (mm units), so 'Actual size' printing is exact.
- Self-contained: Outfit/Barlow fonts + the logo embedded as data URIs, and a
  real vector QR. VERIFIED both QRs decode to https://skate-spots.org (decoded
  with jsQR from a rendered PNG; logo centre, error-correction H).
- New 'Free to print' section in the App Shop: previews, FREE badge, sizes,
  cutting tip, 'Open & print' + 'Save file' buttons, and a 100%/Actual-size note.
- scripts/make-print-art.mjs regenerates the artwork (committed output).
- AGENTS.md documents the assets + the re-verify-the-QR rule.
2026-09-27 22:16:43 +07:00
SkateSpots
059d9da5c4 feat(app-shop): local/on-request sales via DM (drop shipping claims)
- Remove the 'Shipped worldwide' and 'At cost, no markup theatre' chips; the
  remaining chips are Lightning, Cashu, Local handover.
- Copy now says sold locally, on request: message me -> we sort the details ->
  pay in sats at handover. No shipping, at all.
- 'How it'll work' -> 'How buying works' (it's a DM, not a cart): Message me /
  We sort it out / Pay in sats.
- New 'Request an item' CTA in the hero that deep-links into a private DM with
  the shop owner (/messages/<PLATFORM_OWNER_PUBKEYS[0] npub>), so a buyer can
  start the conversation with zero checkout machinery.
- Honesty footer states local handover only / no shipping of any kind.
- AGENTS.md documents the model (no cart/checkout/shipping without a decision).
2026-09-27 21:52:00 +07:00
SkateSpots
8dcc97fd81 feat(app-shop): add the App Shop section (empty on purpose)
New /app-shop page for SkateSpots merch, to be paid in sats only (Lightning or
Cashu) — no cards, no accounts, no middleman.

Design: gradient/glow hero ('Wear the scene' + Coming soon badge), an honest
stock strip (0 items / None pre-orders / Nothing for sale today), an 'In the
works' teaser grid (tees, decks, stickers, tools — dashed + 'soon', no prices),
a three-step 'How it'll work' (pick -> pay in sats -> ships), a newsletter
notify band (the drop lands there first) and a plain-English honesty footer.

Deliberately contains no items: no stock, no prices, no pre-orders, no buy
buttons. AGENTS.md documents that rule so nobody invents a catalogue later.

Wiring: registered in NAV_CATALOG as id 'app-shop' (appears in the desktop
'More' menu AND the mobile burger menu automatically), lazy route in AppRouter,
and added to the smoke-test route list so a broken page aborts deploys.

Verified: page renders desktop + mobile (all sections), listed in both navs,
smoke PASS with /app-shop.
2026-09-27 21:40:41 +07:00
SkateSpots
1ef35c14b8 fix(dms): DM inbox relays were wiped on every load, so DMs went nowhere
Root cause: AppProvider validates the persisted config with
AppConfigSchema.partial().parse(), and RelayMetadataSchema only declared
'relays' + 'updatedAt'. Zod drops unknown keys by default, so dmRelays (and
localRelays + blockedRelays) were SILENTLY STRIPPED on every page load. The DM
inbox relays (kind 10050) are usually not in a user's NIP-65 list, so DM reads
and writes fell back to NIP-65 relays - where the other side isn't listening -
and messages looked like they never went through.

- Declare localRelays/blockedRelays/dmRelays in RelayMetadataSchema.
- Send: publish gift wraps in PARALLEL with per-relay timeouts and resolve on
  the first relay that accepts (a dead relay or auth.nostr1.com no longer
  stalls the send); deliver to the recipient's NIP-65 WRITE relays when they
  have no discoverable 10050 (the old fallback was dead code because our own DM
  relays are always non-empty); discovery of 10050/10002 now runs in parallel.
- Read: query all DM relays in parallel with per-relay timeouts. Previously one
  slow/dead relay burned the shared 15s budget and then the fired abort made
  every remaining relay fail instantly.

Verified with an nsec login + a hostile relay list (dead + auth-required):
before, the wrap reached only write relays and the recipient found 0; after, it
lands on the configured inbox relays and both the recipient and our own copy are
found. Send worst case 20s -> 4.5s.
2026-09-27 19:58:54 +07:00
SkateSpots
4474cc26e9 feat(directions): bigger map + app-like layout and richer route details
- Map is now the hero: h-[38vh] mobile / 400px desktop (was 256), with a metric
  Leaflet scale control.
- New layout: flex column with header/modes/From/map pinned, the details as the
  ONLY scroll region, and Start/Stop pinned at the bottom so it's always
  reachable. Previously the dialog was a single overflow-hidden box, so once the
  map grew everything below the fold (Start, steps) was unreachable.
- Summary rebuilt as stat tiles (distance / time / arrive) plus a detail line:
  step count, honest 'uses driving roads'/'driving estimate' notes, and the
  average speed the engine assumed ('21 km/h avg'; foot keeps the skate cruise
  estimate). Steps header shows the count; step list scrolls taller (max-h-72).
- Wider dialog on desktop (sm:max-w-xl).

Verified in-app: tiles + 9/8/5/5 steps per mode, follow banner still tracks
movement, offline chip still appears, map 371px -> 321px with the new layout.
2026-09-27 19:42:06 +07:00
SkateSpots
5a819d5a31 feat(directions): follow mode, choose-your-start, offline route cache
Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.

Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.

Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.

Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.

Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
2026-09-27 19:11:14 +07:00
SkateSpots
59e5829b2a feat(directions): make the route dialog usable as guidance, not just a preview
Answering 'are we giving the best navigation experience?': no, it was a preview
with dead ends. Fixes the concrete gaps:

- ETA: 'arrive ~08:10 PM' alongside the duration.
- Mode memory: the last travel mode is remembered (skatespots:route-mode), so
  cyclists/drivers aren't reset to Skate/Walk every trip.
- Tappable steps: each step pans the map and highlights; added a 'Whole route'
  re-fit button to zoom back out.
- Taller route map on desktop (h-64 sm:h-80).
- min-h-0 on step rows: the app-wide 44px mobile touch-target rule was making
  every step a 44px slab (rows now 29px).

Still out of scope (documented, not faked): live turn-by-turn following, voice
guidance and offline routing.
2026-09-27 18:47:29 +07:00
SkateSpots
e69bace784 fix(amber,routing): scope Amber prompts to real actions; real per-mode routes + steps
AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)

ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
2026-09-27 18:35:17 +07:00
SkateSpots
e98c996945 fix(newsletter): infinite render loop + issues published to the wrong relays
Audit of the newsletter section found two real bugs.

1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
   depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
   -> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
   markAllRead changed every render -> the page effect (deps include it) re-ran
   after every render -> the mark set a NEW state object -> loop. The
   notification bell looped the same way while open.
   - useLocalStorage: setter is now referentially stable (ref + functional set).
   - useSyncedAppData: returned object is memoized.
   - useReadState: apply reads synced/interactive from refs (stable); marks are
     idempotent (same object when already marked) so React bails.
   - NewsletterPage marks read once per visit (mount-once ref guard).

2) Issues + subscription markers were published via the default pool (the
   author's own NIP-65 relays) but READ via useSkateRelays (community relay +
   reader's relays; logged-out = community relay only), so an admin without
   goskatespots in their list would publish drops nobody could see. Composer,
   backfill panel and the subscribe hook now publish to getWriteRelays() and
   fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
   (no schema change).

Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
2026-09-27 17:40:02 +07:00
SkateSpots
6a442c394f fix(mints): native app fetched /api/mints from the local shell -> HTML/JSON error
On the native app the shell is served from https://localhost, so the relative
/api/mints/known hit the local asset server and returned index.html, surfacing as
'Unexpected token < ... not valid JSON' on /mints.

- Native now fetches https://skate-spots.org/api/mints/known via CapacitorHttp
  (bundled core plugin, no WebView CORS), with a plain-fetch fallback.
- Web/dev unchanged (same-origin relative path via the Netlify redirect / Vite
  proxy).
- HTML or unparseable bodies now become a friendly 'unavailable' message instead
  of a raw JSON parse error.
2026-09-27 17:25:16 +07:00
SkateSpots
4e4c50ab71 fix(nav): desktop 'More' was clipped; mobile menu never listed Marketplace
- Desktop: the nav's overflow-x-auto clips its trailing item when the nav grows,
  which is exactly the 'More' menu. Widened the header (max-w-[1700px] vs the
  1400px page container), tightened nav padding to px-3 and nowrap. 'More' is
  fully visible at 1280/1440/2000px with Marketplace pinned (was missing).
- Mobile: the burger menu hard-coded only Map/Lab/Events/Crews + a few extras, so
  Marketplace (and Mints) were unreachable. It now appends every nav-catalog item
  not already listed, giving mobile/desktop parity (Marketplace, Mints, …).
2026-09-27 17:18:11 +07:00
SkateSpots
0b5230d4ab fix(directions): route map never rendered + dialog stacked under the spot sheet
RouteDialog was effectively broken — users got an empty dialog / 'no route'.

- Map init: the Leaflet map lives in Radix Dialog content, which mounts AFTER
  the first effect pass, so the mapDivRef guard was null, the init effect bailed,
  and the map was never created (no route line, ever). Hold the node in state
  with a callback ref and add it to the effect deps.
- Height: the global .leaflet-container{height:100%} override beats h-64; against
  a height-less parent it resolved to 0 (invisible map). Height now lives on the
  wrapper (relative h-64) and the map is h-full.
- Stacking: from the map, Directions rendered the dialog inside the spot Sheet
  (two nested Radix modals; the sheet covered the route). The map now owns the
  RouteDialog at page level and closes the sheet first.
- Routing transport: call OSRM directly (it sends CORS '*') with the cors proxy
  only as fallback; distinguish busy / no-route / unreachable errors and guard
  against NaN coordinates.
2026-09-27 17:07:44 +07:00
SkateSpots
e5e2f7d7a1 fix(nav,amber,reputation,routing): header crowding, no read-state popups, anti-collusion rep, instant directions
- Header: shrink-0 logo + whitespace-nowrap + gap, nav shrinks/scrolls so it
  can never collide with (or glow over) the SkateSpots wordmark; tighter item
  padding. Fixes the top-left logo/nav overlap.
- Amber: useReadState keeps read markers LOCAL-ONLY for interactive signers, so
  merely opening the bell/DM no longer publishes (encrypt + sign = 2 popups).
- Reputation: lead with verifiedCounterparties (distinct people), cap each
  partner at 3 in the WoT-weighted score, warn when every confirmation is with
  one account. NIP.md documents the collusion limit honestly.
- Directions: skip the live GPS call when the cached fix is <2 min old; the
  blocking 'Finding your location' overlay only shows with no seed (silent
  refine chip otherwise). Near Me also flips the sort to proximity.
- .gitignore: cvm-server/package.json (local dev copy of package.cvm.json).
2026-09-27 16:42:39 +07:00
SkateSpots
821522327c feat(sasquatch): show the owner AI balance on the panel header
Owner-only chip (next to the Pro/CVM chips) showing the upstream sats balance,
amber when low. useSasquatchAiStatus gains an 'enabled' option; the panel only
fetches while it is open, so the NIP-98 signature never fires at app startup.
2026-09-27 15:36:09 +07:00
SkateSpots
69e1ae17b8 docs(agents): note the owner AI-balance status endpoint + card 2026-09-27 15:28:14 +07:00
SkateSpots
972941d688 docs(agents): Routstr is now the AI upstream (githappens node)
Record the working node, the fund/refund recipe, the aggregator outage, the
'orangesync bad key' trap, and that Netlify snapshots env at deploy (redeploy
after env changes).
2026-09-27 15:22:37 +07:00
SkateSpots
00aab5369b feat(ai-proxy): server-authoritative models (provider switch = env only)
If the client asks for a model not in the allowlist, the proxy now uses its
configured default instead of 400-ing. So switching the upstream to a Routstr
node is purely server-env (SASQUATCH_AI_BASE/KEY/MODELS) with no client redeploy.
AGENTS.md documents Routstr discovery (kind 38421, endpoints in 'u' tags), the
live nodes found, and the fund->key recipe.
2026-09-27 15:11:25 +07:00
SkateSpots
a5a134c6c0 feat(ai-proxy): provider-agnostic upstream (enables a sats-only provider)
The proxy now reads its upstream from SASQUATCH_AI_BASE + SASQUATCH_AI_KEY
(falling back to Netlify's injected OPENROUTER_* vars), and the model allowlist
is overridable via SASQUATCH_MODELS. Default behaviour is unchanged (gateway).

This makes switching to a sats-only provider a config change, e.g. Routstr:
  SASQUATCH_AI_BASE=https://api.routstr.com/v1
  SASQUATCH_AI_KEY=cashuA… (raw token) or sk-… (session)
NOTE: api.routstr.com was returning 404 (empty models) on 2026-09-27, so we are
NOT switching yet. Documented in AGENTS.md.
2026-09-27 15:01:13 +07:00
SkateSpots
f94297b196 docs(agents): note the non-site deploy skip 2026-09-27 14:43:02 +07:00
SkateSpots
70440c4bce docs(agents): Pro gate is enabled in production (SASQUATCH_REQUIRE_PRO=1) 2026-09-27 14:32:30 +07:00
SkateSpots
ccc04cc172 feat(sasquatch-pro): server-side Pro gate on the AI proxy + client credentials
- netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1).
  Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the
  caller is a platform owner, an active Pro pass (x-sasquatch-pro = a
  provider-authored zap receipt) verified OFFLINE — signature, user binding,
  platform tag, and paid-amount → tier. Off by default.
- src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per
  message) and attaches the Pro receipt.
- sasquatchBrain: threads auth headers through the proxy (streaming + non).
- useSasquatchPro: exposes the winning receipt event.
- Verified: 200 with the gate off, 401 with it on (env reset afterwards).
2026-09-27 14:18:33 +07:00
SkateSpots
2503a9a2a3 docs(agents): note Sasquatch AI is Pro-only + tier-derived expiry 2026-09-27 14:01:36 +07:00
SkateSpots
087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00
SkateSpots
55b4dd2e86 fix(amber): stop re-decrypting on every navigation
Root cause: Layout is NOT a router layout — ~62 pages wrap themselves in
<Layout>, so its chrome remounts on every route change. The header's
NotificationBell -> useNotificationsRead -> useReadState -> useSyncedAppData
re-decrypts an encrypted kind-30078 event on mount; with Amber (NIP-55) every
nip44.decrypt opens the signer app, so it popped on each menu click.

Fix: for interactive signers, decrypt once per session and serve remounts from
cache (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect false) in
useSyncedAppData and useStash. Non-interactive signers refetch as before.

Follow-up (noted in AGENTS.md): hoist Layout into the router so the chrome mounts
once — removes the whole remount class (and SasquatchGuide state resets).
2026-09-27 13:28:36 +07:00
SkateSpots
1e4f3302dc feat(sasquatch): server-side AI proxy (no key in the client bundle)
- netlify/functions/ai.mjs: POST /api/ai runs against Netlify's AI Gateway —
  Netlify injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into Functions, so no
  key is managed or shipped. Curated model allowlist, best-effort per-IP
  throttle, CORS for web + Capacitor, and SSE streamed straight through.
- sasquatchBrain: call the proxy instead of OpenRouter directly; drop
  VITE_OPENROUTER_API_KEY entirely; PROXY_MODELS set to the slugs that actually
  work via the gateway (its ZDR policy blocks gemini/anthropic/gpt-4o-mini).
- netlify.toml: [functions] directory.
- Verified in production: POST https://skate-spots.org/api/ai -> 200.
2026-09-27 12:24:46 +07:00
SkateSpots
c487d9d1d2 chore(cvm): use only the community relay for data queries
Policy: the CVM server queries a single community relay
(wss://goskatespots.nostr1.com). Align .env.example (and the local .env) to it.
The local duplicate unit has been retired; the server runs on one host only.
2026-09-27 12:07:58 +07:00
SkateSpots
f7855d47e4 chore(cvm): one-command deploy to the single host + document the setup
- scripts/deploy-cvm.sh: backup remote server.ts -> upload -> restart -> confirm
  the heartbeat, with --rollback. Host/SSH values come from the never-committed
  ~/.config/skatespots/cvm.env (or CVM_HOST), so no host details ever enter this
  public repo.
- package.json: add `npm run deploy:cvm`.
- AGENTS.md: record that the CVM server runs on ONE external host (deploy via
  deploy:cvm) and that the discovery relay, not the server, is the SPOF.
2026-09-27 12:04:57 +07:00
SkateSpots
3a32dc2334 feat(cvm-server): load protection + type-check it in the gate
Load protection (public read-only endpoint — protects relays + this box):
- Per-pubkey sliding-window throttle and in-flight cap via inbound middleware.
  The transport can't return a JSON-RPC error, so over-budget callers are dropped
  and time out (logged).
- Global semaphore caps concurrent upstream relay queries.
- Short-TTL cache keyed by filter set, so repeated identical queries (e.g. a
  burst of get_community_stats) don't re-hit the relays.
- All tunable via CVM_RATE_* / CVM_MAX_* / CVM_QUERY_CACHE_MS (documented in
  .env.example); maps pruned by a janitor.

CI:
- cvm-server/tsconfig.json type-checks the standalone server; new
  typecheck:cvm / check:cvm scripts, with typecheck:cvm wired into npm test so
  the server can't rot unnoticed (it was excluded from tsc + eslint).
2026-09-27 11:36:20 +07:00
SkateSpots
b8757eccd0 feat(directions): every Directions button now uses the in-app router
- MapPage: replace the external 'Open with' menu (Google/OSM/Waze) with the
  shared in-app RouteDialog, so the map spot sheet routes natively (incl. the
  Transitous transit mode).
- FoodPage: replace the Google Maps handoff with RouteDialog.
- RouteDialog: remove the opt-in external 'Open in' handoff and delete
  src/lib/directions.ts — no external map apps anywhere (product decision).
2026-09-27 11:25:56 +07:00
SkateSpots
8893622bc3 docs(agents): note Sasquatch Lab-role coverage + CVM server tool parity 2026-09-27 11:17:09 +07:00
SkateSpots
cbd0287447 fix(sasquatch): cover the organizer + barber Lab roles
useCVMClient's findCoaches ROLE_KIND, self-tag set and getCommunityStats stopped
at 35012, so Sasquatch could not find the event-organizer (35013) or barber
(35014) profiles we added. Extend LabRole, ROLE_KIND, SELF_TAGS and the stats
queries/counts (lab_total) to cover all Lab kinds 35001-35014.
2026-09-27 11:12:14 +07:00
SkateSpots
d7bcc35d51 feat(sasquatch): stream the first-pass answer + drop dead Pollinations model
- sasquatchBrain: new streamSasquatchAI — OpenAI-style SSE via OpenRouter (if
  the key is set) then Pollinations; falls back to the non-streaming caller only
  when nothing at all was delivered, so blocked-stream environments still answer.
- SasquatchGuide: stream the first pass into a live bubble (raw JSON tool calls
  are suppressed so partial JSON never shows; prose-then-tool output is dropped),
  finalise the bubble in place, show a blinking cursor, and hide the generic
  'thinking...' bubble while a reply streams.
- Drop Pollinations 'mistral' (now a 404) leaving the live 'openai' slug.
2026-09-27 11:02:56 +07:00
SkateSpots
fdf33d2c45 feat(directions): public-transport mode (Transitous) + open-in-maps handoff
- routing.ts: add a 'transit' travel mode via Transitous (MOTIS 2 API, keyless,
  open CORS), with a local precision-7 polyline decoder and per-leg parsing.
  Road modes stay on keyless OSRM; moto remains ≈car and honestly labelled.
- RouteDialog: transit draws each leg (walk dashed grey, transit coloured by
  mode) and summarises depart→arrive, duration, transfers and line chips, with
  Transitous/OpenStreetMap attribution.
- directions.ts: opt-in 'Open in' handoff to Google / Apple / OpenStreetMap
  (+ Organic Maps via geo: on Android), mode-aware. In-app routing stays the
  default; the description notes transit shares the trip with Transitous.
- No scooter/moped profile (deliberate).
2026-09-27 10:52:26 +07:00
SkateSpots
18cc8218b2 fix(sasquatch): bound AI latency, separate system prompt, verify relay events
- sasquatchBrain: abort each provider request after 15s and give up the whole
  multi-provider attempt after 35s, so a hung OpenRouter/Pollinations call can no
  longer wedge the chat (it now falls through to the static/local fallbacks).
- SasquatchGuide: send the system prompt as a real system message (both the
  first pass and the tool-synthesis pass) instead of smuggling it into a user
  turn, and mark tool output as untrusted — blunts prompt injection via event
  content.
- useCVMClient: signature-verify (verifyEvent) every relay result, matching the
  standalone server and the release-notes claim.
- useCVMServerHealth: stale threshold 1h -> 45m to match the server heartbeat.
2026-09-27 10:32:07 +07:00
SkateSpots
a881a87f4d feat(lab): add Barber / Hairstylist profile (kind 35014)
A full Lab role, mirroring how Event Organizer was added:
- kinds: SKATE_BARBER_KIND 35014, self-tag 'skatebarber', BARBER_*_LABELS,
  SkateBarber + parseSkateBarber (studio, books_open).
- craft config at /become/barber (cyan accent, Sparkles icon), Lab tab
  'Barbers', filter sections (services/styles/setting), empty state, FAB item.
- Lab pipeline: useLabProfiles kind/tag/parser, LabPage ServiceType
  'barbering' + ACCENT/maps/counts, CoachDetailPage per-role maps + studio &
  books-open block, LabLinkField label, growth stats + Community Stats card.
- No crew-scene strip for barbers (LabCrewsStrip returns null).
- Documented in NIP.md; smoke test now boots /become/barber.
2026-09-27 10:15:30 +07:00
SkateSpots
f5ce4002e9 feat(lab,spots): validate contact details + drag & drop photo upload
- Lab forms: a non-empty email must be valid and website/portfolio must
  resolve to an https URL; invalid input shows an inline hint and blocks
  publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram
  are exempt since they may be bare @handles.
- New PhotoDropzone component: drag & drop plus multi-select image input,
  emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage
  (drop several photos at once) and AddPhotoToSpot, replacing the old
  one-file-at-a-time inputs.
- Spot photo uploads run sequentially and dedupe URLs.
2026-09-27 09:46:37 +07:00
SkateSpots
f73a360983 fix(lab): complete offer-a-service audit — data preservation, rendering, stats, input handling
- Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab
  profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages).
- Render previously collected-but-hidden fields on the Lab profile page:
  judge 'events_judged', organizer 'events_hosted', editor hardware/software,
  and spot references.
- Include Event Organizer (35013) in Community Stats + counts.
- Cap Title inputs at 100 (was 120) so profiles no longer fail the parser
  and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names.
- Normalize bare-domain URLs (example.com) to https:// before storing instead
  of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields).
- No crew strip for judging/organizing (was wrongly showing Photo & Film);
  scrollable Offer Services FAB; live bio counters.
2026-09-27 09:38:03 +07:00
SkateSpots
b60b13dc18 feat(lab): add Event Organizer craft profile (kind 35013)
New data-driven Lab role 'Event Organizer / Host' — skaters who put on jams,
comps, meetups, demos and tour stops.
- types.ts: SKATE_ORGANIZER_KIND=35013, ORGANIZER_EVENT/SERVICE/SCALE label
  maps, SkateOrganizer + parseSkateOrganizer (events_hosted extra tag)
- becomeRoles: craft config (teal accent, Tent icon, 3 skill sections)
- useLabProfiles: registered kind/self-tag/parser
- LabPage: Organizers tab, accent, filter drawer section, empty state, Offer
  link, counts
- CoachDetailPage (generic Lab detail): accent/icon/kind/labels/noun
- LabCrewsStrip: union updated
- NIP.md documents kind 35013; smoke covers /lab and /become/organizer

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-27 00:02:39 +07:00
SkateSpots
4d00d0991b chore: untrack accidentally-committed local files (.gitignore)
git add -A from the repo root swept in assets/, resources/, bun.lock and
cvm-server locks/.bak. Untrack them + .gitignore; correct AGENTS.md junk list
(cvm-server *source* is tracked). No secrets were involved (only placeholders).
2026-09-26 23:39:48 +07:00
SkateSpots
b2a6108eeb refactor: split non-component exports (react-refresh) into their own modules
Removes the last react-refresh disables by splitting exports instead:
- contexts/language.ts: Language/Translations types + LanguageContext +
  useTranslation/useT (LanguageProvider stays a component-only file); 12
  importers repointed.
- lib/sortOptions.ts: calculatePopularity + calculateDistance (out of SortDropdown)
- lib/trustFilter.ts: TrustFilterLevel + filterByTrustLevel (out of TrustFilter)
- EventCountdown: deleted the unused useEventStatus hook
- NewsletterPage: dropped the NEWSLETTER_ISSUES re-export shim
- AGENTS.md updated (lint stays clean with no blanket disables)

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:29:42 +07:00
SkateSpots
584561e390 feat(trades): trusted-trader recommendations (31873) + offer DM nudges
- TradeRecommendationsCard on profiles: shows who recommends this trader
  (kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend
  them. New useTraderRecommendations hook.
- OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made;
  ListingOffersPanel DMs the buyer when the seller accepts/declines.
- AGENTS.md notes 31873.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:24:23 +07:00
SkateSpots
7307b00edc feat(offers): structured marketplace offers (kind 30403) with accept/decline
Promotes 'Offer' from a prefilled DM to a real, queryable event:
- Buyer publishes a 30403 offer (d/a/p + optional amount + message)
- Seller sees incoming offers on the listing detail page and can Accept/Decline
  (publishes a 30403 response referencing the offer)
- Buyer sees their offer's status and can Withdraw it
- New: useMarketplaceOffers (parse + hooks), OfferDialog, ListingOffersPanel
- NIP.md documents kind 30403; AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:17:51 +07:00
SkateSpots
e0e41ae625 feat(trades): revoke/dispute a trade record (s=revoked)
On /trades, each authored trade record gets a 'Revoke' action (with a confirm
dialog) that republishes the 31871 with s=revoked — retracting it from
reputation for a disputed or mistaken deal. The counterparty keeps their own
attestation. Revoked records are already excluded from aggregation.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:00:10 +07:00
SkateSpots
5b163c632f feat(trades): My Trades page + block-filter DM relays
- New '/trades' page: action-needed (incoming 'confirm this sale' requests as
  seller, pending confirmations as buyer) with inline actions, a reputation
  card, and the user's own trade records (role/counterparty/state) linking to
  each listing. Nav entry (auth-only) + smoke route.
- DM inbox relays (kind 10050) are now block-filtered on both read and delivery
  (closes the last relay caveat).
- AGENTS.md updated.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:56:52 +07:00
SkateSpots
78f643d7a9 feat(mints,escrow): MintRadar polish + escrow status in shared actions
- Mints page: 'Online only' toggle + result count ('N of M mints')
- Wallet page: 'Browse Cashu mints' link to /mints
- ListingTradeActions now renders the EscrowPanel for a viewer's active escrow,
  so escrow status shows on both the grid card and the detail page
- AGENTS.md project map: marketplace detail page + shared actions

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:39:58 +07:00
SkateSpots
21a0390ed5 refactor(relays): route every relay consumer through useRelays()
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
  (NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
  useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:36:14 +07:00
SkateSpots
c0aec3d912 chore(lint): reach zero warnings (react-refresh)
The last 7 react-refresh warnings are dev-only HMR DX from intentional
component+hook/helper colocations (EventCountdown, SortDropdown, TrustFilter,
LanguageContext, NewsletterPage). Splitting them into new files is broad import
churn for no runtime benefit, so each carries a justified file-level disable.

npm test: 0 errors, 0 warnings, 101 tests; boot smoke green.
2026-09-26 22:23:45 +07:00
SkateSpots
6def78fffe chore(lint): clear all no-unused-vars warnings (position-precise)
Removed/renamed the remaining 39 unused locals/dead code, file-by-file:
- unused destructured bindings/params (remove the binding, keep the call)
- unused single-line pure consts and dead functions/interfaces removed
- deleted the dead _FeedItem component and its now-unused imports
- amber _readChunks self-call fixed; SpotMap popup call site updated

Warnings: 182 -> 7 (only react-refresh/only-export-components remain).
no-explicit-any=0, exhaustive-deps=0, no-unused-vars=0.
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline + guidance updated.
2026-09-26 22:20:21 +07:00
SkateSpots
721a5d4b67 chore(lint): clear all react-hooks/exhaustive-deps warnings
Triage per file (not a blanket sweep):
- Memoise shared getters with useCallback and depend on them:
  EventsPage/LabPage/MapPage getTrustScore+getCommentCount (+MapPage getZapTotal)
- Memoise derived values that were recreated each render: CrewChat 'messages',
  RiderCardPage 'cardData'
- Justified eslint-disable for intentional mount-only effects with a comment:
  AppProvider (seed defaults/theme), SpotMap (Leaflet init once),
  SpamFeePayment (auto-pay keyed to invoice/state, not the recreated handler)
- EventCountdown: keep the intentional 'tick' recompute dep (documented disable)

exhaustive-deps: 14 -> 0. Warnings 60 -> 46 (39 unused-vars, 7 react-refresh).
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline updated.
2026-09-26 22:09:43 +07:00
SkateSpots
dc305fbc08 docs(agents): lint baseline 60 warnings + note unsafe mass unused-var fixes 2026-09-26 21:59:44 +07:00
SkateSpots
d1f8870379 chore(lint): remove all explicit any + sweep dead imports
- Fix all 12 no-explicit-any (NPool, NostrEvent[], LabProfile, structural
  Cluster type, explicit SkateSpot cast) -> 0 remaining
- Remove unused imports/vars across 48 files (manual + a verified codemod for
  unused import specifiers only); tsc + tests + boot smoke all green
- Warning backlog: 182 -> 76 (remaining: ~55 no-unused-vars locals,
  14 react-hooks/exhaustive-deps, 7 react-refresh)
- AGENTS.md baseline updated
2026-09-26 21:40:42 +07:00
SkateSpots
30ef511e78 feat(relays): NIP-17 DM inbox relays (kind 10050)
- RelayMetadata.dmRelays; Relays UI to add/remove, publishing kind 10050
- NostrSync fetches the user's 10050 on login (alongside 10002)
- DMs deliver gift wraps to recipients' + own DM relays (fallback: write relays)
  and read from own DM relays + NIP-65 read relays
- useDmRelays() to look up others' DM relay lists

Also fixes a latent data-loss bug: NostrSync's 10002 fetch replaced
relayMetadata wholesale, which would wipe the new local/blocked/dm lists on
login — it now merges. 'Trusted' category intentionally skipped (no distinct
behaviour -> no empty UI); documented in AGENTS.md.
2026-09-26 21:32:53 +07:00
SkateSpots
e75c4a8355 feat(relays): Local + Blocked relay categories
Add a purposeful subset of Amethyst-style relay categories (not a full clone):
- App-local 'Local relays' (never published; added to reads+writes)
- App-local 'Blocked relays' (excluded from every read/write)
- src/lib/relays.ts: resolveRelays()/useRelays() + URL normalisation
- NostrProvider req/event routers go through resolveRelays (blocklist applied)
- RelayListManager: Local/Blocked editors; NIP-65 save now preserves the new
  lists instead of overwriting relayMetadata

Deliberately omitted (documented in AGENTS.md): KeyPackage (10051, no MLS
consumer) and proxy/broadcast/private-home (no such discovery model here).
Follow-up noted: a few hooks still read config.relayMetadata.relays directly.
2026-09-26 21:26:13 +07:00
SkateSpots
a1c30cad4e feat(mints): Cashu mint directory (MintRadar) + same-origin proxy
New '/mints' page: browse Community MintRadar mints with health (online/latency/
degraded/archived), NUT count, terms, and add-to-wallet on native.

MintRadar has no CORS, so it's fetched same-origin at /api/mints/*:
- prod: Netlify proxy redirect in public/_redirects (+ 10m cache in _headers)
- dev: Vite server.proxy in vite.config.ts
This also hides users' IPs from MintRadar. Data is unofficial/rate-limited;
the page degrades to a friendly error state. Nav entry + smoke route added.
2026-09-26 21:20:19 +07:00
SkateSpots
6f28d1fe2a docs(escrow): on-device test checklist
Add a step-by-step Android verification checklist to ESCROW.md (preconditions,
advertise key -> buyer escrow -> seller redeem -> refund -> guard rails) and
point to it from the Status section and AGENTS.md. Escrow stays unverified until
every box passes on a device with tiny amounts.
2026-09-26 21:14:11 +07:00
SkateSpots
15901976ef feat(marketplace): wire Cashu escrow into the trade flow (native)
Completes the escrow feature on top of the tested core (src/lib/cashuEscrow.ts):
- useCashuEscrow: native-only hook wiring createEscrow (cashu-ts mint + NWC/WebLN
  funding), redeemEscrow, escrow-key derivation (coco keyring) and profile publish
- EscrowKeyCard (Wallet): advertise the kind-0 'cashu_escrow' pubkey
- EscrowRequestDialog (listing card): buyer locks funds (P2PK to seller, refundable
  after N days) and sends the 31872 carrying the cashu_token
- EscrowPanel + 'Redeem escrow' in the DM request banner (seller side); the panel
  is offline/parse-based so it renders on web too
- Buyer 'Escrow & request' supersedes the plain request when available

EXPERIMENTAL: mint-touching paths are unverified on a device (native-only wallet);
use small amounts first. Gate: 101 tests, 0 errors.
2026-09-26 21:08:36 +07:00
SkateSpots
26e951710e feat(marketplace): Cashu escrow core (time-locked P2PK) + design
Real, oracle-free escrow built on Cashu P2PK with a locktime + refund key:
buyer locks funds to the seller; seller redeems on delivery; the buyer can
reclaim after the deadline. Locked (not plain) tokens are what make the
cashu_token on a public 31872 safe.

- src/lib/cashuEscrow.ts: pubkey normalisation (x-only -> compressed), P2PK
  policy builder, offline token inspection (amount/mint/locktime/refund), and
  an injectable createEscrowToken mint flow (quote -> pay -> mintProofs{p2pk})
- 15 unit tests incl. the mint orchestration against a fake wallet
- Protocol: advertise a 'cashu_escrow' pubkey in kind 0 (NIP.md); token rides
  the existing 31872 cashu_token tag
- ESCROW.md: design, threat model, status, limitations
- AGENTS.md: project map + docs index updated

Wallet UI (cashu-ts + payment rail + profile publish) is native-only and still
needs on-device verification.
2026-09-26 21:01:31 +07:00
SkateSpots
8255d9d5e8 docs(agents): make AGENTS.md the maintained cross-session memory
- Add a 'this file is the ONLY persistent memory' directive: record non-obvious
  learnings in the same session, fix stale entries immediately, date claims
- Add a 'Project map' (HashRouter, custom kinds, marketplace/wallet/DM/lazy
  routes, npm test baselines, untracked-junk list)
- Fix stale facts: BrowserRouter -> HashRouter; /src/pages + /src/lib were
  understated; describe the smoke test's hash routes + crash-screen detection
- Strengthen System Prompt Management to treat AGENTS.md as long-term memory
2026-09-26 20:51:12 +07:00
SkateSpots
4e17852df3 fix(routes): self-healing lazy routes; document dev dep-cache crash
Symptom: in the long-running dev server some lazy routes (/settings, /stash,
/collections, /stats) hit the route error boundary ('This page slammed') while
others worked. Console: 'Failed to fetch dynamically imported module'; network:
504 for node_modules/.vite/deps/*.js. Vite's dep-optimizer cache had gone
missing after the server re-optimized on a lockfile change, leaving stale ?v=
hashes.

- Add lazyWithRetry: on a chunk-load failure, retry once, then do a single
  full reload per session (avoids reload loops); clears the flag on success
- Use it for all 53 lazy routes in AppRouter
- Document the dev dep-cache gotcha + fix in AGENTS.md
2026-09-26 20:36:14 +07:00
SkateSpots
acdde497b4 feat(deploy): boot-test the built bundle before deploying; document the vite chunking gotcha 2026-09-26 16:57:17 +07:00
SkateSpots
43d4773ce0 feat(deploy): add Netlify auto-deploy watcher (systemd timer) + ignore .netlify 2026-09-26 16:41:42 +07:00
SkateSpots
f56980ffcf docs(AGENTS): add project-specific dev, build, signing and release workflows 2026-09-26 16:21:49 +07:00
shakespeare.diy
27e14994b5 Import files from skate-spots project
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 20:04:15 +07:00
shakespeare.diy
06fae294b8 New project created with Shakespeare
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 19:49:07 +07:00