From 67e422307a3587e1912d426958056b1e7e5b6bb7 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 9 Sep 2026 10:54:22 +0700 Subject: [PATCH] Citrine audit: endpoint fallback, a11y, reduced-motion, UX polish (1.3.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full self-audit of the Citrine feature. Fixes found: Robustness - Probe now tries BOTH ws://127.0.0.1 and ws://localhost and routes to whichever actually answers (probeCitrineEndpoints + CitrineProbeResult.url). We no longer bet the whole feature on one loopback address form; useCitrine.url reflects the winning endpoint so reads/writes go to the right place. Correctness - Preview toggle was mutating the REAL setting while in dev-preview; handleToggle is now a no-op under preview (forced state owns the visuals). Accessibility / "flawless" bar - Respect prefers-reduced-motion: status pips, chip pop-in and the expand slide now motion-reduce:animate-none (no looping glow/pulse for reduced-motion users). - Recheck button gets an aria-label; status text is role="status" aria-live so screen readers announce searching→detected transitions; decorative pips are aria-hidden. UX copy - "Waiting" state no longer presumes Citrine isn't installed. Clearer two-step guidance (Open Citrine → Start; come back → Recheck) covering the common "installed but not started/detected yet" case. Honest note: MIXED_CONTENT_ALWAYS_ALLOW (1.3.5) is the high-probability fix for the 0-connections block but is not device-verified from here; the dual-endpoint probe hedges the second most likely cause (address form). Co-authored-by: shakespeare.diy --- package.json | 2 +- src/hooks/useCitrine.ts | 14 +++++++++----- src/lib/citrine.ts | 38 +++++++++++++++++++++++++++++++++++++- src/pages/SettingsPage.tsx | 32 +++++++++++++++++++++++--------- 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 3d6dff5..69816f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.3.5", + "version": "1.3.6", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/useCitrine.ts b/src/hooks/useCitrine.ts index 7c889dd..f448878 100644 --- a/src/hooks/useCitrine.ts +++ b/src/hooks/useCitrine.ts @@ -5,7 +5,7 @@ import { CITRINE_RELAY_URL, citrinePreview, isCitrinePossible, - probeCitrine, + probeCitrineEndpoints, } from '@/lib/citrine'; /** @@ -31,11 +31,13 @@ export function useCitrine() { const enabled = config.useCitrineRelay ?? false; const { data: probe, refetch, isFetching, isFetched } = useQuery({ - queryKey: ['citrine-reachable', CITRINE_RELAY_URL], + queryKey: ['citrine-reachable'], // Only bother probing when the platform could host Citrine AND the user has // opted in — no point connecting to localhost otherwise. enabled: possible && enabled, - queryFn: () => probeCitrine(CITRINE_RELAY_URL), + // Try each candidate endpoint (127.0.0.1 / localhost) and use whichever + // actually answers as a Nostr relay. + queryFn: () => probeCitrineEndpoints(), staleTime: 60_000, // Poll only while we're still WAITING for Citrine (not yet reachable), so a // freshly-started relay is picked up automatically. Once connected, stop the @@ -80,8 +82,10 @@ export function useCitrine() { /** Diagnostic reason from the last probe (shown on-device for debugging). */ reason: probe?.reason ?? null, /** The relay URL we probe/connect to — shown in the debug readout. */ - probeUrl: CITRINE_RELAY_URL, - url: active ? CITRINE_RELAY_URL : null, + probeUrl: probe?.url ?? CITRINE_RELAY_URL, + // Route to the endpoint that actually answered (127.0.0.1 or localhost), + // falling back to the default when active but somehow missing a url. + url: active ? (probe?.url ?? CITRINE_RELAY_URL) : null, /** * DEV PREVIEW ONLY — a forced UI state ('off'|'searching'|'live'|'waiting') * when the citrine:preview flag is set, else null. Cosmetic; does not affect diff --git a/src/lib/citrine.ts b/src/lib/citrine.ts index 023a657..6c6ad4d 100644 --- a/src/lib/citrine.ts +++ b/src/lib/citrine.ts @@ -25,6 +25,17 @@ /** Citrine's default localhost relay endpoint. */ export const CITRINE_RELAY_URL = 'ws://127.0.0.1:4869'; +/** + * Endpoints to try, in order. Citrine listens on the loopback interface, but + * depending on the device/WebView the reachable form can be the numeric IP or + * the `localhost` hostname — so we probe both and use whichever answers rather + * than betting the feature on one address form. + */ +export const CITRINE_RELAY_CANDIDATES = [ + 'ws://127.0.0.1:4869', + 'ws://localhost:4869', +]; + /** Forcible UI states for the dev preview flag. */ export type CitrinePreviewState = 'off' | 'searching' | 'live' | 'waiting'; const PREVIEW_KEY = 'citrine:preview'; @@ -92,6 +103,8 @@ export interface CitrineProbeResult { ok: boolean; /** Short reason for the outcome, surfaced in the UI for debugging. */ reason: string; + /** The endpoint that answered (on success), so routing uses the right one. */ + url?: string; } /** @@ -130,7 +143,7 @@ export function probeCitrine( } catch { // ignore close errors } - resolve({ ok, reason }); + resolve({ ok, reason, url: ok ? url : undefined }); }; try { @@ -191,3 +204,26 @@ export function probeCitrine( }; }); } + +/** + * Probe each candidate endpoint in order, returning the first that answers as a + * real Nostr relay (with the winning `url`). If none answer, returns the last + * failure's reason so the UI can still show a meaningful diagnostic. Tries them + * sequentially (not in parallel) — the list is tiny and the first candidate + * almost always wins, so this avoids opening several sockets at once. + */ +export async function probeCitrineEndpoints( + candidates: readonly string[] = CITRINE_RELAY_CANDIDATES, + timeoutMs = 3000, +): Promise { + if (!isCitrinePossible()) { + return { ok: false, reason: 'not-android' }; + } + + let last: CitrineProbeResult = { ok: false, reason: 'no-endpoints' }; + for (const candidate of candidates) { + last = await probeCitrine(candidate, timeoutMs); + if (last.ok) return last; + } + return last; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ebf9f93..d823d10 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -301,6 +301,11 @@ function CitrineCard() { const probing = citrine.probing || state === 'searching'; const handleToggle = () => { + // In dev preview we don't mutate the real setting — the forced state owns + // the visuals. Toggling is a no-op for review (avoids a misleading toast and + // flipping the underlying config while inspecting the UI). + if (citrine.preview) return; + const next = !citrine.enabled; citrine.setEnabled(next); toast({ @@ -350,8 +355,9 @@ function CitrineCard() { {enabled && (