Citrine: add safe dev-preview flag to review the UI in a browser (1.3.2)

The CitrineCard is Android-only (renders null off-native), so its four visual
states couldn't be reviewed without an APK build. Adds a strictly cosmetic
preview switch:

  localStorage.setItem('citrine:preview', '1')         // show card, real behaviour
  localStorage.setItem('citrine:preview', 'live')      // force connected state
  localStorage.setItem('citrine:preview', 'waiting')   // force not-detected
  localStorage.setItem('citrine:preview', 'searching') // force searching
  localStorage.removeItem('citrine:preview')           // back to normal

Safety: the flag unlocks RENDERING ONLY via isCitrinePossible(). It never sets
citrine.active and never yields a relay url, so no query or publish is ever
routed anywhere different — production relay behaviour is identical with or
without the flag. A forced state is purely visual (enabled/probing are derived
locally in the card for preview; the real probe still governs active/url).

lib/citrine.ts: citrinePreview()/isCitrinePreview() + CitrinePreviewState.
useCitrine: exposes `preview`. CitrineCard: forced state overrides the computed
state for review only.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-08 19:18:51 +07:00
parent 2e6a20dc2b
commit 335fb40f72
4 changed files with 74 additions and 15 deletions

View file

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

View file

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { import {
CITRINE_RELAY_URL, CITRINE_RELAY_URL,
citrinePreview,
isCitrinePossible, isCitrinePossible,
probeCitrine, probeCitrine,
} from '@/lib/citrine'; } from '@/lib/citrine';
@ -46,6 +47,10 @@ export function useCitrine() {
const active = possible && enabled && reachable; const active = possible && enabled && reachable;
// DEV PREVIEW ONLY: a forced visual state for browser design review. Purely
// cosmetic — it never affects `active`/`url`, so no traffic is ever rerouted.
const preview = citrinePreview();
const setEnabled = useCallback( const setEnabled = useCallback(
(next: boolean) => { (next: boolean) => {
updateConfig((current) => ({ ...current, useCitrineRelay: next })); updateConfig((current) => ({ ...current, useCitrineRelay: next }));
@ -67,6 +72,12 @@ export function useCitrine() {
*/ */
settled: isFetched, settled: isFetched,
url: active ? CITRINE_RELAY_URL : null, url: active ? 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
* `active`/`url`. The Settings card uses it to override its computed state.
*/
preview,
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). */
recheck: refetch, recheck: refetch,

View file

@ -25,6 +25,45 @@
/** 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';
/** Forcible UI states for the dev preview flag. */
export type CitrinePreviewState = 'off' | 'searching' | 'live' | 'waiting';
const PREVIEW_KEY = 'citrine:preview';
/**
* DEV PREVIEW ONLY — lets the (normally Android-only) Citrine UI render in a
* browser so the design can be reviewed without an APK build.
*
* Set in the browser console:
* localStorage.setItem('citrine:preview', '1') // show the card, real behaviour
* localStorage.setItem('citrine:preview', 'live') // force the "connected" state
* localStorage.setItem('citrine:preview', 'waiting') // force "not detected"
* localStorage.setItem('citrine:preview', 'searching')// force "searching…"
* localStorage.removeItem('citrine:preview') // back to normal
*
* CRITICAL: this unlocks RENDERING ONLY. It never makes the local relay
* `active` and never yields a relay `url`, so no query or publish is ever routed
* anywhere different — production relay behaviour is completely unchanged. A
* forced state is purely cosmetic.
*/
export function citrinePreview(): CitrinePreviewState | null {
try {
const raw = (globalThis as { localStorage?: Storage }).localStorage?.getItem(PREVIEW_KEY);
if (!raw) return null;
if (raw === 'off' || raw === 'searching' || raw === 'live' || raw === 'waiting') {
return raw;
}
// Any other truthy value ("1"/"true") = show the card with real behaviour.
return 'off';
} catch {
return null;
}
}
/** True if the dev preview flag is set at all (any value). */
export function isCitrinePreview(): boolean {
return citrinePreview() !== null;
}
/** /**
* True when running inside a Capacitor native Android shell. Citrine is an * True when running inside a Capacitor native Android shell. Citrine is an
* Android app, so the local relay can only exist there. This never throws in a * Android app, so the local relay can only exist there. This never throws in a
@ -39,13 +78,13 @@ export function isAndroidNative(): boolean {
} }
/** /**
* True if the Citrine relay URL *could* be reachable on this device — i.e. we're * True if the Citrine UI/feature should be offered on this device — native
* on native Android. This is a cheap synchronous gate used to decide whether to * Android, OR the dev preview flag is set (browser design review). Actual
* even offer the feature / attempt a probe. Actual reachability is confirmed by * reachability is still confirmed separately by probeCitrine(), which fails
* probeCitrine(). * cleanly in a browser, so preview never routes real traffic.
*/ */
export function isCitrinePossible(): boolean { export function isCitrinePossible(): boolean {
return isAndroidNative(); return isAndroidNative() || isCitrinePreview();
} }
/** /**

View file

@ -283,7 +283,9 @@ 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 // Normal computed state. In dev preview (citrine.preview set), a forced state
// overrides it purely for visual review — real relay behaviour is unaffected.
const computedState: 'off' | 'searching' | 'live' | 'waiting' = !citrine.enabled
? 'off' ? 'off'
: citrine.reachable : citrine.reachable
? 'live' ? 'live'
@ -291,6 +293,13 @@ function CitrineCard() {
? 'searching' ? 'searching'
: 'waiting'; : 'waiting';
const state = citrine.preview ?? computedState;
// Treat any non-off forced preview state as "enabled" so the expanded detail
// (chips / CTA / status) renders for review.
const enabled = citrine.preview ? citrine.preview !== 'off' : citrine.enabled;
// Spinner reflects a real probe, or a forced "searching" preview state.
const probing = citrine.probing || state === 'searching';
const handleToggle = () => { const handleToggle = () => {
const next = !citrine.enabled; const next = !citrine.enabled;
citrine.setEnabled(next); citrine.setEnabled(next);
@ -339,7 +348,7 @@ function CitrineCard() {
)} )}
/> />
</div> </div>
{citrine.enabled && ( {enabled && (
<span <span
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',
@ -386,23 +395,23 @@ function CitrineCard() {
onClick={handleToggle} onClick={handleToggle}
className={cn( className={cn(
'relative w-11 h-6 rounded-full transition-all duration-300 flex-shrink-0 active:scale-95', 'relative w-11 h-6 rounded-full transition-all duration-300 flex-shrink-0 active:scale-95',
citrine.enabled ? 'bg-[#ccff00]' : 'bg-white/10', enabled ? 'bg-[#ccff00]' : 'bg-white/10',
)} )}
role="switch" role="switch"
aria-checked={citrine.enabled} aria-checked={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 duration-300 ease-out', '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', enabled ? 'left-[calc(100%-22px)]' : 'left-0.5',
)} )}
/> />
</button> </button>
</div> </div>
{/* Expanding detail — only when enabled */} {/* Expanding detail — only when enabled */}
{citrine.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">
{/* Status row + recheck */} {/* Status row + recheck */}
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
@ -430,14 +439,14 @@ function CitrineCard() {
</span> </span>
<button <button
onClick={() => citrine.recheck()} onClick={() => citrine.recheck()}
disabled={citrine.probing} disabled={probing}
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', citrine.probing && 'animate-spin')} /> <RefreshCw className={cn('w-3 h-3', probing && 'animate-spin')} />
{citrine.probing ? 'Checking' : 'Recheck'} {probing ? 'Checking' : 'Recheck'}
</button> </button>
</div> </div>