The private stash was deny-listed from persistence with no local cache, so an
offline or relay-outage cold start showed an EMPTY stash even though decrypting
needs no network.
- offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never
the decrypted list). useStash caches it on every successful fetch, and both
the read query and fetchLatestStash (the pre-write re-fetch) fall back to it --
so the stash loads at the park and an offline edit rebuilds from the real list
instead of throwing.
- /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with
Export (a JSON you control) and Import (one re-encrypt, skipping
title+category+size duplicates).
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.
Tap the destination in the header to search and change where you're going:
- New DestinationSearch: searches skate spots, skate shops and BTCMap merchants
(merchants come from a radius around the current destination - BTCMap has no
global name search - and are labelled as such). Mounted only while the search
is open, so opening Directions pays nothing for it; spot/shop limits match the
map page so the cache is reused.
- RouteDialog now owns the destination internally (seeded from the prop on open,
not on every render) so the search can change it without re-opening the dialog,
and calls onDestinationChange so the parent stays in sync (wired in MapPage).
- The Leaflet map is no longer rebuilt when the destination changes: it's created
from a ref once per open, and a small effect just moves the destination marker
(the route effect re-fits the view). This avoids a teardown/recreate flash.
The directory we already fetch returns review ratings, reliability, uptime and
liveness-audit stats that the client was throwing away (it only typed ~12 of ~35
fields). Now:
- MintDirectoryEntry types the community fields (rating/weighted rating/count,
reliabilityScore, uptime 24h+7d, audit volume + recent errors, serverLocation,
units, mint/melt methods, pubkey, discoveredAt, lastError).
- Row shows the rating (e.g. 4.6 (30)) plus reliability and 24h uptime.
- Detail sheet shows rating, reliability, uptime, location, latency, NUTs,
units, version, plus a liveness-audit line and an honest note that written
reviews live on MintRadar (linked), not in the directory API.
Written comments are genuinely not in MintRadar's /api/mints/known (probed a few
plausible review endpoints - all 404), so the next step for in-app comments
would be Nostr-native mint reviews.
SheetContent renders a built-in close X, and SpotBottomSheet adds its own in
the header row - so tapping a spot showed two. Added an opt-in hideClose prop
to SheetContent and used it on the spot sheet (which keeps its own, placed in
the header). Also padded the new mint sheet header clear of the built-in close.
- Mints: the whole row is now clickable and opens a detail sheet with every
field the directory has (status, latency, NUTs, version, last online,
description), Copy URL / Open / Terms actions, an Add button, and an honest
'what is a mint + mints can rug' explainer.
- Crews: replaced the horizontally-scrolling category chips + More/Less toggle
with one compact type control that opens a bottom sheet listing every focus
(single-select). No more swiping the chips on a phone, and nothing that reads
as a category when it isn't.
- Directions: the flash was three things - the dialog's scale-in animation while
Leaflet measured itself, a bg-muted map box showing before tiles load, and an
animated fitBounds jump when the route arrived. Fixed with a second
invalidateSize after the animation, a dark map background, and
animate:false on the route fit. Following is smoother too: re-centre only
after ~6m of movement, with a gentle 0.6s pan, so GPS jitter doesn't twitch
the map.
- 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.
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.
Mobile map was paying for a stacked toolbar under the header, leaving the map
~78% of the viewport.
- MapPage: the toolbar is absolute over the map on mobile (gradient fade,
pointer-events pass-through) and static on desktop (md:static); controls get
translucent/blurred backgrounds for legibility over the map.
- Container height now subtracts header + bottom nav only (desktop footer
subtraction removed); Layout hides the desktop footer on full-bleed routes.
- Loading pill / offline banner nudged below the floating bar on mobile.
Measured at 390x844 (headless): map starts under the header and fills 730px =
86.5% of the viewport (was ~78%).
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.
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.
The profile screen was a bare 'view my public profile' link, a banner that
wrongly said 'Profile not loaded' whenever the user simply had no name yet,
a directory card, then the form.
- ProfilePreviewCard: banner + avatar + handle + bio + badges, so you can see
how you look to everyone else without leaving the page. The public-profile
link moves into it.
- The reload banner now only shows when we have NO metadata at all, and says so
honestly instead of keying off a missing name.
- ProfileChecklist: a compact nudge listing only genuinely empty essentials
(name, photo, bio, lightning address, handle); hidden once complete.
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).
The Supporters page is where someone is already asking 'who funds this?', and
its Support section already explains the Angor rail — the Lightning element sits
right beside it (public zap / pay quietly). Removed from About.
Adds components/SupportSkateSpots — two honest paths to send sats to the
project, reusing existing rails (no new deps, no backend):
- Zap SkateSpots: NIP-57 zap to the platform pubkey (public + optional comment,
needs a signer).
- Pay quietly: plain LNURL invoice from the platform lightning address
(anonymous, works logged out).
Payment runs NWC -> WebLN -> QR fallback, exactly like the Pro flow. Copy is
explicit that this is support, not a purchase (buys nothing, no refunds), and
that merch/Pro are separate.
- 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.
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.
#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.
- BTCMap (the Bitcoin-places map layer) sees your IP and the area you're viewing.
- Zaps are public by default: they tie your account/lightning address to the post
and the tip; notes that a wallet can send them anonymously.
- /privacy IP section now lists Cashu mints, directions (OSRM/Transitous receive
the route endpoints), and Sasquatch AI — and states the sharp bit: a relay can
link your IP to your pubkey ('this person, from this address').
- Corrects the old 'none of these receive your GPS location' claim, which stopped
being true once directions shipped (they get your origin/destination).
- Notes the one we DO shield: the mints directory is proxied through our server,
so MintRadar never sees the user's IP.
- Removes the named VPN brand; copy is now neutral ('use one you pay for and
trust; treat free as a warning sign').
- Settings -> Relays gets a one-line privacy note linking to /privacy.
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.
Relay outages no longer look like 'no spots'. querySkateContent swallowed every
relay error and returned [], so a dead relay would blank the map AND overwrite
good cached data with an empty list (then persist it). Now:
- querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when
NO relay answered. Only the map's own feeds use it, so the blast radius is
small (79 other call sites keep today's behaviour).
- useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure,
so the map still works at the park; they only error when nothing is saved.
- The persister refuses to persist an empty array, so an outage can't wipe the
last-known map on the next cold start.
- MapPage shows an honest 'Offline · showing saved spots' banner (reusing the
existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card
instead of falsely claiming 'No spots mapped yet'.
Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves
the name so nobody else can claim it.
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.
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.
- Review queue: past verdicts (e.g. the 13 hidden labels from an Aug/Sep cleanup
sweep) are correct but noisy — they buried the items that still need a
decision. They are now COLLAPSED behind a 'Show actioned (N)' toggle, with the
verdicts untouched (still hidden) and 'Undo all N' still available. Verified:
the Spots tab renders 0 hidden cards by default with the toggle + Undo all.
- public/images/screenshots/{map,spot,marketplace}.png (1280x800, quantised) for
the app-listing gallery.
Spot page order is now: title/badges -> description -> Comments -> Gear Swap ->
Spot Chat. Comments are the conversation about the spot, so they belong next to
the description rather than below the transactional blocks.
Verified in the browser by text position: description(379) < Comments(404) <
GEAR SWAP(507) < Spot Chat(618). tsc/eslint clean, 101 tests, smoke PASS.
- 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.
- 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.
(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.
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.
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.
- 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.
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.
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).
- 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.
- Remove the 0 / None / Nothing tiles (items in stock, pre-orders, for sale
today) — the honesty footer already says there is no stock.
- 'In the works' cards are now just icon + label + SOON badge, no small print
('Heavyweight...', 'Small numbered runs...', etc).
Verified in the browser: strip and blurbs gone, everything else intact.
- 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).
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.
- NewsletterIssue gains an optional 'image' (per-issue cover); IssueArticle renders
it above the header and buildIssueArticle uses it as the NIP-23 'image' tag
(falling back to the spot-of-the-week image).
- NEWSLETTER_ISSUES now leads with #006 (2026-09-27): the Nostr-Twitter-Clone
rant, the Directions fixes (route never drew + identical per-mode numbers),
follow mode, choose-your-start + offline saved routes, Sasquatch Pro
(sats-paid, streamed, personal tools, one signature per session), the
Marketplace hub + 1-10 deck ratings, Mints (directory + the native JSON-error
fix), Amber scoping, the DM inbox-relay bug, and the polish list.
- Cover: https://skate-spots.org/images/drop-006-cover.png (hosted in
public/images, added in e075ee8).
Verified: the archive renders the cover + #006 copy, and the 'Drop #006 is out'
pill shows on other pages for 7 days. tsc/eslint clean, 101 tests, smoke PASS.
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.
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.
- 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).
- Reviews: confirming a trade now lets the reviewer set a 1-10 skateboard-deck
rating, stored as ["rating","n"] on the kind-31871 attestation (NIP.md updated).
The Reputation tab shows each review's decks plus an average.
- New DeckRating component (self-drawn deck glyph) and TradeConfirmRow (rating
picker + confirm), used for both seller-confirm and buyer-confirm in My Trades.
- New 'Selling' tab: your marketplace listings (active/sold) with one-tap
Mark sold.
- /marketplace is now a hub with a sticky tab bar: Browse (the gear grid),
My Trades (existing flow), and Reputation — the trader's reputation score,
peer recommendations, and a Reviews/trade-records list built from kind-31871
attestations (who, sold/bought, method, amount, note, link to the listing).
- Nav: the old 'trades' entry becomes 'Marketplace' -> /marketplace (no longer
auth-only, since browsing is public).
- /trades and /listing/... deep links keep working.
- sasquatchBrain: PROXY_MODELS now match the Routstr node's slugs
(gemini-3-6-flash, z-ai-glm-5-3, gemini-3-7-flash).
- ai.mjs: new owner-only POST /api/ai/status (NIP-98 + owner pubkey) returning the
upstream balance + a 'low' flag (<500 sats); config.path now serves both routes.
- useSasquatchAiStatus + SasquatchAiStatusCard (owner-only, in Settings): shows the
balance and warns when low — the top-up nudge, no webhook/notification key needed.
The pay-to address was duplicated (PRO_LIGHTNING_ADDRESS and
PLATFORM_LIGHTNING_ADDRESS) and hardcoded in the tour zap-split; a typo in any
copy would send money nowhere. Now one source of truth in src/lib/platform.ts,
re-exported by the fee/Pro modules.
Also surface the address in the Sasquatch Pro dialog ('Pays to ⚡ <address>') so
users can see where their sats go, in both the auto-pay and manual-QR paths.
- 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.
- 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).