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:
parent
2e6a20dc2b
commit
335fb40f72
4 changed files with 74 additions and 15 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue