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:
parent
c7e890e65c
commit
67e422307a
4 changed files with 70 additions and 16 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
{enabled && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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 === 'searching' && 'bg-[#ccff00] animate-status-breathe',
|
||||
state === 'waiting' && 'bg-orange-500',
|
||||
|
|
@ -412,18 +418,21 @@ function CitrineCard() {
|
|||
|
||||
{/* Expanding detail — only when 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 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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 === 'searching' && 'bg-[#ccff00] animate-status-breathe',
|
||||
state === 'waiting' && 'bg-orange-500',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'font-medium',
|
||||
state === 'live'
|
||||
|
|
@ -440,19 +449,20 @@ function CitrineCard() {
|
|||
<button
|
||||
onClick={() => citrine.recheck()}
|
||||
disabled={probing}
|
||||
aria-label="Re-check for the Citrine relay"
|
||||
className={cn(
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<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'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LIVE: benefit chips — the reward for connecting */}
|
||||
{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: WifiOff, label: 'Works offline' },
|
||||
|
|
@ -473,11 +483,15 @@ function CitrineCard() {
|
|||
{state === 'waiting' && (
|
||||
<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">
|
||||
<strong className="text-orange-300">Citrine isn't running yet.</strong>{' '}
|
||||
Install the free app, open it and start the relay, then tap Recheck.
|
||||
It runs a private Nostr relay on your phone for instant reads and an
|
||||
offline backup.
|
||||
<strong className="text-orange-300">Can't reach Citrine.</strong>{' '}
|
||||
Make sure the free Citrine app is installed and its relay is
|
||||
<em> started</em>, then tap Recheck. It runs a private Nostr relay
|
||||
on your phone for instant reads and an offline backup.
|
||||
</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
|
||||
href="https://github.com/greenart7c3/Citrine"
|
||||
target="_blank"
|
||||
|
|
|
|||
Loading…
Reference in a new issue