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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue