Citrine audit: endpoint fallback, a11y, reduced-motion, UX polish (1.3.6)

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 10:54:22 +07:00
parent c7e890e65c
commit 67e422307a
4 changed files with 70 additions and 16 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.3.5", "version": "1.3.6",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -5,7 +5,7 @@ import {
CITRINE_RELAY_URL, CITRINE_RELAY_URL,
citrinePreview, citrinePreview,
isCitrinePossible, isCitrinePossible,
probeCitrine, probeCitrineEndpoints,
} from '@/lib/citrine'; } from '@/lib/citrine';
/** /**
@ -31,11 +31,13 @@ export function useCitrine() {
const enabled = config.useCitrineRelay ?? false; const enabled = config.useCitrineRelay ?? false;
const { data: probe, refetch, isFetching, isFetched } = useQuery({ 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 // Only bother probing when the platform could host Citrine AND the user has
// opted in — no point connecting to localhost otherwise. // opted in — no point connecting to localhost otherwise.
enabled: possible && enabled, 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, staleTime: 60_000,
// Poll only while we're still WAITING for Citrine (not yet reachable), so a // 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 // 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). */ /** Diagnostic reason from the last probe (shown on-device for debugging). */
reason: probe?.reason ?? null, reason: probe?.reason ?? null,
/** The relay URL we probe/connect to — shown in the debug readout. */ /** The relay URL we probe/connect to — shown in the debug readout. */
probeUrl: CITRINE_RELAY_URL, probeUrl: probe?.url ?? CITRINE_RELAY_URL,
url: active ? CITRINE_RELAY_URL : null, // 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') * DEV PREVIEW ONLY — a forced UI state ('off'|'searching'|'live'|'waiting')
* when the citrine:preview flag is set, else null. Cosmetic; does not affect * when the citrine:preview flag is set, else null. Cosmetic; does not affect

View file

@ -25,6 +25,17 @@
/** Citrine's default localhost relay endpoint. */ /** Citrine's default localhost relay endpoint. */
export const CITRINE_RELAY_URL = 'ws://127.0.0.1:4869'; 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. */ /** Forcible UI states for the dev preview flag. */
export type CitrinePreviewState = 'off' | 'searching' | 'live' | 'waiting'; export type CitrinePreviewState = 'off' | 'searching' | 'live' | 'waiting';
const PREVIEW_KEY = 'citrine:preview'; const PREVIEW_KEY = 'citrine:preview';
@ -92,6 +103,8 @@ export interface CitrineProbeResult {
ok: boolean; ok: boolean;
/** Short reason for the outcome, surfaced in the UI for debugging. */ /** Short reason for the outcome, surfaced in the UI for debugging. */
reason: string; reason: string;
/** The endpoint that answered (on success), so routing uses the right one. */
url?: string;
} }
/** /**
@ -130,7 +143,7 @@ export function probeCitrine(
} catch { } catch {
// ignore close errors // ignore close errors
} }
resolve({ ok, reason }); resolve({ ok, reason, url: ok ? url : undefined });
}; };
try { 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<CitrineProbeResult> {
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;
}

View file

@ -301,6 +301,11 @@ function CitrineCard() {
const probing = citrine.probing || state === 'searching'; const probing = citrine.probing || state === 'searching';
const handleToggle = () => { 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; const next = !citrine.enabled;
citrine.setEnabled(next); citrine.setEnabled(next);
toast({ toast({
@ -350,8 +355,9 @@ function CitrineCard() {
</div> </div>
{enabled && ( {enabled && (
<span <span
aria-hidden="true"
className={cn( className={cn(
'absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black', 'absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black motion-reduce:animate-none',
state === 'live' && 'bg-[#ccff00] animate-status-live', state === 'live' && 'bg-[#ccff00] animate-status-live',
state === 'searching' && 'bg-[#ccff00] animate-status-breathe', state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
state === 'waiting' && 'bg-orange-500', state === 'waiting' && 'bg-orange-500',
@ -412,18 +418,21 @@ function CitrineCard() {
{/* Expanding detail — only when enabled */} {/* Expanding detail — only when enabled */}
{enabled && ( {enabled && (
<div className="px-4 pb-4 space-y-3 animate-in fade-in slide-in-from-top-1 duration-300"> <div className="px-4 pb-4 space-y-3 animate-in fade-in slide-in-from-top-1 duration-300 motion-reduce:animate-none">
{/* Status row + recheck */} {/* Status row + recheck */}
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span <span
aria-hidden="true"
className={cn( className={cn(
'inline-flex w-2 h-2 rounded-full', 'inline-flex w-2 h-2 rounded-full motion-reduce:animate-none',
state === 'live' && 'bg-[#ccff00] animate-status-live', state === 'live' && 'bg-[#ccff00] animate-status-live',
state === 'searching' && 'bg-[#ccff00] animate-status-breathe', state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
state === 'waiting' && 'bg-orange-500', state === 'waiting' && 'bg-orange-500',
)} )}
/> />
<span <span
role="status"
aria-live="polite"
className={cn( className={cn(
'font-medium', 'font-medium',
state === 'live' state === 'live'
@ -440,19 +449,20 @@ function CitrineCard() {
<button <button
onClick={() => citrine.recheck()} onClick={() => citrine.recheck()}
disabled={probing} disabled={probing}
aria-label="Re-check for the Citrine relay"
className={cn( className={cn(
'ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 -mr-1 text-muted-foreground', 'ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 -mr-1 text-muted-foreground',
'hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50', 'hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50',
)} )}
> >
<RefreshCw className={cn('w-3 h-3', probing && 'animate-spin')} /> <RefreshCw className={cn('w-3 h-3 motion-reduce:animate-none', probing && 'animate-spin')} />
{probing ? 'Checking' : 'Recheck'} {probing ? 'Checking' : 'Recheck'}
</button> </button>
</div> </div>
{/* LIVE: benefit chips — the reward for connecting */} {/* LIVE: benefit chips — the reward for connecting */}
{state === 'live' && ( {state === 'live' && (
<div className="flex flex-wrap gap-1.5 animate-pop-in"> <div className="flex flex-wrap gap-1.5 animate-pop-in motion-reduce:animate-none">
{[ {[
{ icon: Zap, label: 'Instant reads' }, { icon: Zap, label: 'Instant reads' },
{ icon: WifiOff, label: 'Works offline' }, { icon: WifiOff, label: 'Works offline' },
@ -473,11 +483,15 @@ function CitrineCard() {
{state === 'waiting' && ( {state === 'waiting' && (
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2.5"> <div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2.5">
<p className="text-[11px] leading-relaxed text-orange-100/90"> <p className="text-[11px] leading-relaxed text-orange-100/90">
<strong className="text-orange-300">Citrine isn't running yet.</strong>{' '} <strong className="text-orange-300">Can't reach Citrine.</strong>{' '}
Install the free app, open it and start the relay, then tap Recheck. Make sure the free Citrine app is installed and its relay is
It runs a private Nostr relay on your phone for instant reads and an <em> started</em>, then tap Recheck. It runs a private Nostr relay
offline backup. on your phone for instant reads and an offline backup.
</p> </p>
<ol className="text-[11px] leading-relaxed text-orange-100/70 list-decimal list-inside space-y-0.5">
<li>Open Citrine → tap <strong className="text-orange-200">Start</strong></li>
<li>Come back here → tap <strong className="text-orange-200">Recheck</strong></li>
</ol>
<a <a
href="https://github.com/greenart7c3/Citrine" href="https://github.com/greenart7c3/Citrine"
target="_blank" target="_blank"