Citrine: native cleartext config, default-pool write wiring, polished UI (1.3.1)

Completes the local-relay integration so it actually works end-to-end and feels
first-class.

Native (Android):
- android-templates/network_security_config.xml — permits cleartext for
  LOOPBACK ONLY (127.0.0.1 / localhost / ::1); all real traffic stays TLS. This
  is what lets ws://127.0.0.1 (Citrine) connect on API 28+, which otherwise
  silently blocks cleartext.
- scripts/inject-network-security.mjs — idempotently copies the XML into
  res/xml/ and adds android:networkSecurityConfig to <application>, mirroring
  the existing inject-amber-queries pattern so it survives `cap sync`.
- Wired into android:init / android:sync / android:build.

Reads AND writes now reach Citrine app-wide:
- NostrProvider's NPool routers (reqRouter/eventRouter) fold in the active
  Citrine URL via a ref, so every default-pool query hits it first (instant
  local reads) and every useNostrPublish write also lands on-device (backup +
  offline capture). Previously only explicit useSkateRelays paths benefited.
- A dedicated effect refreshes ['nostr'] queries when the local relay comes/goes
  so reads switch over immediately. Fully no-op off-Android (url stays null).

UI/UX (Relays settings CitrineCard):
- Four crisp states — off / searching / live / waiting — with a breathing then
  glowing status pip, benefit chips (Instant reads · Works offline · On-device
  backup) that pop in on connect, a spinning Recheck, an actionable install CTA,
  and a persistent encryption-reassurance note.
- Graceful first-run: shows "searching" until the first probe settles (via new
  useCitrine.settled), never flashing the orange "not detected" state.

Tailwind: added status-breathe / status-live / pop-in keyframes+animations.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-08 19:11:51 +07:00
parent bd9b71095d
commit 2e6a20dc2b
7 changed files with 352 additions and 42 deletions

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android network security config for SkateSpots.
WHY THIS EXISTS
───────────────
Android API 28+ defaults cleartextTrafficPermitted to FALSE. The optional
local Citrine relay (github.com/greenart7c3/Citrine) listens on a plaintext
loopback WebSocket (ws://127.0.0.1 / ws://localhost). Without a cleartext
allow-entry the OS silently refuses that socket, so the in-app "Local Relay"
probe can never turn green even when Citrine is running.
SCOPE — deliberately minimal:
• Cleartext is permitted ONLY for loopback addresses (127.0.0.1, ::1,
localhost). Nothing else.
• The base config keeps cleartextTrafficPermitted="false", so ALL real
network traffic (wss:// relays, https:// APIs, Blossom, etc.) stays
encrypted and is unaffected. This is strictly narrower than flipping the
global usesCleartextTraffic flag.
Kept as the source of truth in android-templates/ and copied into
android/app/src/main/res/xml/ on every build by
scripts/inject-network-security.mjs (idempotent).
-->
<network-security-config>
<!-- Everything not explicitly listed below: no cleartext, encrypted only. -->
<base-config cleartextTrafficPermitted="false" />
<!-- Loopback only: the on-device Citrine relay. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">::1</domain>
</domain-config>
</network-security-config>

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.1",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {
@ -9,11 +9,11 @@
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'", "build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs", "android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs", "android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs",
"android:open": "npx cap open android", "android:open": "npx cap open android",
"android:version": "node scripts/sync-android-version.mjs", "android:version": "node scripts/sync-android-version.mjs",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && cd android && ./gradlew assembleRelease", "android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && cd android && ./gradlew assembleRelease",
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html" "android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html"
}, },
"dependencies": { "dependencies": {

View file

@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Inject the network security config that allows the optional local Citrine
* relay's loopback cleartext WebSocket (ws://127.0.0.1).
*
* WHY THIS EXISTS
* ───────────────
* Android API 28+ blocks cleartext (non-TLS) traffic by default. Citrine
* (github.com/greenart7c3/Citrine) runs an on-device Nostr relay on a plaintext
* localhost WebSocket, so without a loopback-scoped cleartext allow-entry the OS
* silently refuses the connection and the in-app "Local Relay" toggle can never
* detect it.
*
* This script (idempotent, part of the android build):
* 1. Copies android-templates/network_security_config.xml into
* android/app/src/main/res/xml/.
* 2. Adds android:networkSecurityConfig="@xml/network_security_config" to the
* <application> tag in AndroidManifest.xml if not already present.
*
* The config permits cleartext for LOOPBACK ONLY — all real traffic (wss://,
* https://) stays encrypted. Exits quietly if android/ isn't generated yet.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const appDir = join(root, 'android', 'app');
if (!existsSync(appDir)) {
console.log('[inject-network-security] android/ not generated yet — skipping.');
process.exit(0);
}
// ── 1. Copy the XML resource into res/xml/ ──────────────────────────────────
const template = join(root, 'android-templates', 'network_security_config.xml');
if (!existsSync(template)) {
console.error('[inject-network-security] template android-templates/network_security_config.xml missing — aborting.');
process.exit(1);
}
const xmlDir = join(appDir, 'src', 'main', 'res', 'xml');
if (!existsSync(xmlDir)) {
mkdirSync(xmlDir, { recursive: true });
}
const target = join(xmlDir, 'network_security_config.xml');
const desired = readFileSync(template, 'utf8');
const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
if (current !== desired) {
copyFileSync(template, target);
console.log('[inject-network-security] ✓ Wrote res/xml/network_security_config.xml.');
} else {
console.log('[inject-network-security] res/xml/network_security_config.xml already correct.');
}
// ── 2. Reference it from the <application> tag in the manifest ──────────────
const manifestPath = join(appDir, 'src', 'main', 'AndroidManifest.xml');
if (!existsSync(manifestPath)) {
console.log('[inject-network-security] AndroidManifest.xml not found — skipping manifest wiring.');
process.exit(0);
}
let manifest = readFileSync(manifestPath, 'utf8');
if (manifest.includes('android:networkSecurityConfig')) {
console.log('[inject-network-security] manifest already references a network security config.');
process.exit(0);
}
// Add the attribute to the opening <application ...> tag. Capacitor always
// emits an <application> element; we insert the attribute right after the tag
// name so it merges cleanly regardless of the other attributes' order.
const appTagRegex = /<application\b/;
if (!appTagRegex.test(manifest)) {
console.error('[inject-network-security] No <application> tag found — aborting.');
process.exit(1);
}
manifest = manifest.replace(
appTagRegex,
'<application android:networkSecurityConfig="@xml/network_security_config"',
);
writeFileSync(manifestPath, manifest);
console.log('[inject-network-security] ✓ Added android:networkSecurityConfig to <application>.');

View file

@ -3,6 +3,7 @@ import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify';
import { NostrContext } from '@nostrify/react'; import { NostrContext } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { useCitrine } from '@/hooks/useCitrine';
import { SKATE_RELAY } from '@/lib/types'; import { SKATE_RELAY } from '@/lib/types';
interface NostrProviderProps { interface NostrProviderProps {
@ -21,6 +22,7 @@ const DEFAULT_RELAYS = [
const NostrProvider: React.FC<NostrProviderProps> = (props) => { const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const { children } = props; const { children } = props;
const { config } = useAppContext(); const { config } = useAppContext();
const citrine = useCitrine();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -30,6 +32,10 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
// Use refs so the pool always has the latest data // Use refs so the pool always has the latest data
const relayMetadata = useRef(config.relayMetadata); const relayMetadata = useRef(config.relayMetadata);
const useSkateRelay = useRef(config.useSkateRelay ?? true); const useSkateRelay = useRef(config.useSkateRelay ?? true);
// Local Citrine relay URL when active (Android + opted-in + reachable), else
// null. Kept in a ref so the once-initialized pool's routers always read the
// current value without recreating the pool.
const citrineUrl = useRef<string | null>(citrine.url);
// Invalidate Nostr queries when relay metadata changes // Invalidate Nostr queries when relay metadata changes
useEffect(() => { useEffect(() => {
@ -38,6 +44,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
queryClient.invalidateQueries({ queryKey: ['nostr'] }); queryClient.invalidateQueries({ queryKey: ['nostr'] });
}, [config.relayMetadata, config.useSkateRelay, queryClient]); }, [config.relayMetadata, config.useSkateRelay, queryClient]);
// Keep the Citrine ref current and refresh queries when the local relay comes
// or goes, so reads immediately start (or stop) hitting it.
useEffect(() => {
citrineUrl.current = citrine.url;
queryClient.invalidateQueries({ queryKey: ['nostr'] });
}, [citrine.url, queryClient]);
// Initialize NPool only once // Initialize NPool only once
if (!pool.current) { if (!pool.current) {
pool.current = new NPool({ pool.current = new NPool({
@ -66,6 +79,14 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
routes.set(SKATE_RELAY, filters); routes.set(SKATE_RELAY, filters);
} }
// --- Local Citrine relay (Android, opt-in, reachable) ---
// Query the on-device relay alongside remotes. NPool runs routes in
// parallel and the localhost hit returns first (~1ms), so reads feel
// instant while remotes reconcile in the background.
if (citrineUrl.current) {
routes.set(citrineUrl.current, filters);
}
// Ensure we always have at least one relay // Ensure we always have at least one relay
if (readRelays.length === 0 && routes.size === 0) { if (readRelays.length === 0 && routes.size === 0) {
console.warn('[NostrProvider] No read relays configured, using defaults'); console.warn('[NostrProvider] No read relays configured, using defaults');
@ -106,6 +127,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
allRelays.add(url); allRelays.add(url);
} }
// Local Citrine relay (Android, opt-in, reachable): write here too so
// the device keeps a durable copy (backup) and captures the event even
// when offline, ready to sync to remotes when connectivity returns.
if (citrineUrl.current) {
allRelays.add(citrineUrl.current);
}
// Ensure we always have at least one relay // Ensure we always have at least one relay
if (allRelays.size === 0) { if (allRelays.size === 0) {
console.warn('No write relays configured, using defaults'); console.warn('No write relays configured, using defaults');

View file

@ -29,7 +29,7 @@ export function useCitrine() {
const possible = isCitrinePossible(); const possible = isCitrinePossible();
const enabled = config.useCitrineRelay ?? false; const enabled = config.useCitrineRelay ?? false;
const { data: reachable = false, refetch } = useQuery({ const { data: reachable = false, refetch, isFetching, isFetched } = useQuery({
queryKey: ['citrine-reachable', CITRINE_RELAY_URL], queryKey: ['citrine-reachable', CITRINE_RELAY_URL],
// 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.
@ -58,6 +58,14 @@ export function useCitrine() {
enabled, enabled,
reachable, reachable,
active, active,
/** True while a reachability probe is in flight (for spinner UI). */
probing: isFetching,
/**
* True once a probe has completed at least once. Used to avoid flashing the
* "not detected" state on the very first render after enabling, before the
* initial probe has had a chance to run.
*/
settled: isFetched,
url: active ? CITRINE_RELAY_URL : null, url: active ? CITRINE_RELAY_URL : null,
setEnabled, setEnabled,
/** Re-run the reachability probe (e.g. after the user starts Citrine). */ /** Re-run the reachability probe (e.g. after the user starts Citrine). */

View file

@ -47,6 +47,8 @@ import {
Smartphone, Smartphone,
Download, Download,
Globe, Globe,
Zap,
WifiOff,
} from 'lucide-react'; } from 'lucide-react';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -268,6 +270,12 @@ function Nip05Screen({ onBack }: { onBack: () => void }) {
* non-Android platforms (citrine.possible === false). When enabled, the local * non-Android platforms (citrine.possible === false). When enabled, the local
* relay is folded into skate-content reads (instant local hits) and writes * relay is folded into skate-content reads (instant local hits) and writes
* (on-device backup + offline capture) via useSkateRelays. * (on-device backup + offline capture) via useSkateRelays.
*
* UX states:
* off — muted, invites the user to speed things up.
* searching — enabled, probe in flight: breathing lime dot.
* live — enabled + reachable: glowing lime, benefit chips, celebratory.
* waiting — enabled + not reachable: orange, actionable install CTA.
*/ */
function CitrineCard() { function CitrineCard() {
const citrine = useCitrine(); const citrine = useCitrine();
@ -275,101 +283,213 @@ function CitrineCard() {
// Not on native Android → the local relay can't exist. Hide entirely. // Not on native Android → the local relay can't exist. Hide entirely.
if (!citrine.possible) return null; if (!citrine.possible) return null;
const state: 'off' | 'searching' | 'live' | 'waiting' = !citrine.enabled
? 'off'
: citrine.reachable
? 'live'
: citrine.probing || !citrine.settled
? 'searching'
: 'waiting';
const handleToggle = () => { const handleToggle = () => {
const next = !citrine.enabled; const next = !citrine.enabled;
citrine.setEnabled(next); citrine.setEnabled(next);
toast({ toast({
title: next ? 'Local relay enabled' : 'Local relay disabled', title: next ? 'Local relay on' : 'Local relay off',
description: next description: next
? 'SkateSpots will read from and back up to Citrine on this device.' ? 'Looking for Citrine on this device…'
: 'No longer using the on-device relay.', : 'No longer using the on-device relay.',
}); });
}; };
return ( return (
<div className="rounded-2xl bg-white/3 border border-white/8 overflow-hidden"> <div
<div className="flex items-center justify-between p-4"> className={cn(
'rounded-2xl border overflow-hidden transition-all duration-500',
state === 'live'
? 'border-[#ccff00]/40 bg-[#ccff00]/[0.05] shadow-[0_0_28px_-8px_rgba(204,255,0,0.35)]'
: state === 'waiting'
? 'border-orange-500/25 bg-orange-500/[0.04]'
: 'border-white/8 bg-white/3',
)}
>
{/* Header row */}
<div className="flex items-center justify-between gap-3 p-4">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-[#ccff00]/10 flex items-center justify-center flex-shrink-0"> {/* Icon tile with live status pip */}
<Smartphone className="w-4 h-4 text-[#ccff00]" /> <div className="relative flex-shrink-0">
<div
className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors duration-500',
state === 'live'
? 'bg-[#ccff00]/15'
: state === 'waiting'
? 'bg-orange-500/10'
: 'bg-white/5',
)}
>
<Smartphone
className={cn(
'w-[18px] h-[18px] transition-colors duration-500',
state === 'live'
? 'text-[#ccff00]'
: state === 'waiting'
? 'text-orange-400'
: 'text-white/70',
)}
/>
</div>
{citrine.enabled && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black',
state === 'live' && 'bg-[#ccff00] animate-status-live',
state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
state === 'waiting' && 'bg-orange-500',
)}
/>
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-bold text-white flex items-center gap-2"> <p className="text-sm font-bold text-white flex items-center gap-2 leading-none">
Local Relay Local Relay
<span className="text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70"> <span
className={cn(
'text-[9px] font-black uppercase tracking-[0.15em] transition-colors',
state === 'live' ? 'text-[#ccff00]' : 'text-white/40',
)}
>
Citrine Citrine
</span> </span>
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5"> <p
{citrine.enabled className={cn(
? citrine.reachable 'text-xs mt-1 transition-colors duration-300',
? 'Connected — reading & backing up on-device' state === 'live'
: 'Enabled — waiting for Citrine to run' ? 'text-[#ccff00]/90'
: 'Faster reads & offline backup on this device'} : state === 'waiting'
? 'text-orange-300/80'
: 'text-muted-foreground',
)}
>
{state === 'off' && 'Faster reads & offline backup on this device'}
{state === 'searching' && 'Searching for Citrine on this device…'}
{state === 'live' && 'Connected — reading & backing up locally'}
{state === 'waiting' && 'Enabled — start Citrine to connect'}
</p> </p>
</div> </div>
</div> </div>
{/* Toggle */}
<button <button
onClick={handleToggle} onClick={handleToggle}
className={cn( className={cn(
'relative w-11 h-6 rounded-full transition-all flex-shrink-0', 'relative w-11 h-6 rounded-full transition-all duration-300 flex-shrink-0 active:scale-95',
citrine.enabled ? 'bg-[#ccff00]' : 'bg-white/10', citrine.enabled ? 'bg-[#ccff00]' : 'bg-white/10',
)} )}
role="switch"
aria-checked={citrine.enabled}
aria-label="Toggle local Citrine relay" aria-label="Toggle local Citrine relay"
> >
<span <span
className={cn( className={cn(
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all', 'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all duration-300 ease-out',
citrine.enabled ? 'left-[calc(100%-22px)]' : 'left-0.5', citrine.enabled ? 'left-[calc(100%-22px)]' : 'left-0.5',
)} )}
/> />
</button> </button>
</div> </div>
{/* Expanding detail — only when enabled */}
{citrine.enabled && ( {citrine.enabled && (
<div className="px-4 pb-4 -mt-1 space-y-3"> <div className="px-4 pb-4 space-y-3 animate-in fade-in slide-in-from-top-1 duration-300">
{/* Status row + recheck */}
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span <span
className={cn( className={cn(
'inline-block w-2 h-2 rounded-full', 'inline-flex w-2 h-2 rounded-full',
citrine.reachable ? 'bg-[#ccff00]' : 'bg-orange-500', state === 'live' && 'bg-[#ccff00] animate-status-live',
state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
state === 'waiting' && 'bg-orange-500',
)} )}
/> />
<span className={citrine.reachable ? 'text-[#ccff00]' : 'text-orange-400'}> <span
{citrine.reachable ? 'Citrine relay detected' : 'Citrine not detected'} className={cn(
'font-medium',
state === 'live'
? 'text-[#ccff00]'
: state === 'searching'
? 'text-[#ccff00]/70'
: 'text-orange-400',
)}
>
{state === 'live' && 'Citrine relay detected'}
{state === 'searching' && 'Looking for Citrine…'}
{state === 'waiting' && 'Citrine not detected'}
</span> </span>
<button <button
onClick={() => citrine.recheck()} onClick={() => citrine.recheck()}
className="ml-auto inline-flex items-center gap-1 text-muted-foreground hover:text-white transition-colors" disabled={citrine.probing}
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="w-3 h-3" /> Recheck <RefreshCw className={cn('w-3 h-3', citrine.probing && 'animate-spin')} />
{citrine.probing ? 'Checking' : 'Recheck'}
</button> </button>
</div> </div>
{!citrine.reachable && ( {/* LIVE: benefit chips — the reward for connecting */}
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2"> {state === 'live' && (
<p className="text-[11px] leading-relaxed text-orange-200/90"> <div className="flex flex-wrap gap-1.5 animate-pop-in">
Install the free <strong>Citrine</strong> app and start its relay, {[
then tap Recheck. It runs a private Nostr relay on your phone for { icon: Zap, label: 'Instant reads' },
instant reads and an offline backup. { icon: WifiOff, label: 'Works offline' },
{ icon: Database, label: 'On-device backup' },
].map(({ icon: Icon, label }) => (
<span
key={label}
className="inline-flex items-center gap-1 rounded-full bg-[#ccff00]/12 border border-[#ccff00]/25 px-2.5 py-1 text-[10px] font-bold text-[#ccff00]"
>
<Icon className="w-3 h-3" />
{label}
</span>
))}
</div>
)}
{/* WAITING: install / start CTA */}
{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.
</p> </p>
<a <a
href="https://github.com/greenart7c3/Citrine" href="https://github.com/greenart7c3/Citrine"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-[#ccff00] hover:underline" className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-black hover:bg-[#ccff00]/90 active:scale-95 transition-all"
> >
<Download className="w-3 h-3" /> Get Citrine <Download className="w-3.5 h-3.5" /> Get Citrine
<ExternalLink className="w-2.5 h-2.5" /> <ExternalLink className="w-2.5 h-2.5" />
</a> </a>
</div> </div>
)} )}
<p className="text-[10px] leading-relaxed text-muted-foreground/60"> {/* Privacy reassurance — always present when enabled */}
Private data (Stash, messages) stays end-to-end encrypted here just <div className="flex items-start gap-2 rounded-xl bg-black/20 border border-white/5 p-2.5">
like anywhere else. The local relay only speeds things up and keeps a <Shield className="w-3.5 h-3.5 text-white/40 flex-shrink-0 mt-0.5" />
copy on your device — it never replaces encryption. <p className="text-[10px] leading-relaxed text-muted-foreground/70">
</p> Your Stash and messages stay end-to-end encrypted here, exactly like
everywhere else. The local relay only speeds things up and keeps a
copy on your device — it never replaces encryption.
</p>
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -107,11 +107,45 @@ export default {
'94%, 98%': { '94%, 98%': {
transform: 'rotate(12deg)' transform: 'rotate(12deg)'
} }
},
'status-breathe': {
'0%, 100%': {
opacity: '1',
boxShadow: '0 0 0 0 rgba(204,255,0,0)'
},
'50%': {
opacity: '0.55',
boxShadow: '0 0 0 4px rgba(204,255,0,0.12)'
}
},
'status-live': {
'0%, 100%': {
boxShadow: '0 0 6px 0 rgba(204,255,0,0.9)'
},
'50%': {
boxShadow: '0 0 10px 2px rgba(204,255,0,0.55)'
}
},
'pop-in': {
'0%': {
transform: 'scale(0.6)',
opacity: '0'
},
'70%': {
transform: 'scale(1.08)'
},
'100%': {
transform: 'scale(1)',
opacity: '1'
}
} }
}, },
animation: { animation: {
'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out' 'accordion-up': 'accordion-up 0.2s ease-out',
'status-breathe': 'status-breathe 1.8s ease-in-out infinite',
'status-live': 'status-live 2.4s ease-in-out infinite',
'pop-in': 'pop-in 0.4s cubic-bezier(0.34,1.56,0.64,1) both'
} }
} }
}, },