From a2d9bb6ea51daadaf6979c3b57b5f429a4e4bce9 Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Mon, 3 Aug 2026 00:27:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20runtime=20demo=20mode=20=E2=80=94?= =?UTF-8?q?=20one=20build=20serves=20demo=20+=20real=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ends the two-build divergence (a VITE_DEMO build vs a real build, on separate Vercel projects that drift). Demo mode is now decided at RUNTIME: - api.ts: DEMO reads `?demo=1` (persisted in localStorage; `?demo=0` exits), falling back to the VITE_DEMO build flag for the existing demo project. NET = helperConfigured() && !DEMO, so the SAME canonical build can point at a hosted helper AND still show the demo (mock) on `?demo=1`. - Landing: the primary CTA enters demo mode (`?demo=1#/vaults`) instead of a build-flagged deploy. - DemoBanner: an "Exit demo" link (`?demo=0`) so demo mode is never a trap. Live-validated: default = real (no banner), `?demo=1` = demo (banner + mock + exit) and persists across in-app navigation, `?demo=0` exits. 76 vitest green; tsc + build clean. Follow-up (Vercel dashboard, not code): consolidate to one canonical deploy, retire/redirect the stale net-preview project, and point the submission demo URL at `?demo=1` (or keep VITE_DEMO=1). --- ui/src/App.css | 4 ++++ ui/src/api.ts | 29 ++++++++++++++++++++--------- ui/src/components.tsx | 6 ++++-- ui/src/i18n/en.ts | 1 + ui/src/i18n/pt-BR.ts | 1 + ui/src/screens/Intro.tsx | 5 +++-- 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/ui/src/App.css b/ui/src/App.css index b9f8bff..c49e246 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -545,3 +545,7 @@ select{appearance:none; -webkit-appearance:none; background-image:url("data:imag .cer-kv{display:flex; gap:10px; align-items:baseline; font-size:12px; margin-top:3px} .cer-k{min-width:70px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.04em; font-size:10px; font-family:var(--font-mono)} .cer-kv code{font-family:var(--font-mono); font-size:11.5px; color:var(--silver); word-break:break-all} + +/* Exit link on the demo banner (#60 runtime demo) */ +.demo-banner-exit{margin-left:12px; text-decoration:underline; color:inherit; opacity:.85; font-weight:600} +.demo-banner-exit:hover{opacity:1} diff --git a/ui/src/api.ts b/ui/src/api.ts index 05817d8..785829f 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -71,19 +71,30 @@ export type Balance = { const ENV = import.meta.env as Record const BASE: string = ENV.VITE_API_BASE ?? '' -// Hosted-demo mode (Vercel, no backend). When set, reads that fail (they all do without a -// bridge) fall back to a coherent mock dataset so every screen renders fully populated. -// `health()` is deliberately NOT affected, so the "demo/offline" pill still shows. -const DEMO = ENV.VITE_DEMO === '1' -/** True in the hosted demo build: screens should load api data (which falls back to - * the coherent mock) even though `health()` is false. */ +// Demo mode, decided at RUNTIME (issue #60) so ONE build serves both the demo (mock data) and the +// real app — no build-time VITE_DEMO / separate deploy needed. `?demo=1` enters demo mode and +// persists it (localStorage); `?demo=0` exits. VITE_DEMO stays as a build-time fallback so the +// existing demo project keeps working. When set, reads that fail fall back to a coherent mock +// dataset so every screen renders fully populated; `health()` is NOT affected, so the demo pill shows. +const DEMO = (() => { + if (typeof window === 'undefined') return ENV.VITE_DEMO === '1' + try { + const q = new URLSearchParams(window.location.search) + if (q.get('demo') === '1') { localStorage.setItem('konclave.demo', '1'); return true } + if (q.get('demo') === '0') { localStorage.removeItem('konclave.demo'); return false } + if (localStorage.getItem('konclave.demo') === '1') return true + } catch { /* storage unavailable — fall through to the build flag */ } + return ENV.VITE_DEMO === '1' +})() +/** True in demo mode (runtime `?demo=1` or the VITE_DEMO fallback): screens load api data (which + * falls back to the coherent mock) even though `health()` is false. */ export const IS_DEMO = DEMO // Browser-native mode (Etapa 3 convergence): when a hosted blind helper is configured, the PWA // screens (Dashboard / Proposals / Ledger) read the SELECTED /net vault from the helper instead of -// the local bridge, so the same polished app operates the browser-born vault. Gated on -// `helperConfigured()`, so the local-first path and the submission demo (no helper) are unchanged. -const NET = helperConfigured() +// the local bridge, so the same polished app operates the browser-born vault. Never in demo mode — +// demo always uses mock, so the same canonical build can point at a helper AND still show the demo. +const NET = helperConfigured() && !DEMO /** True when the app operates a browser-native (/net) vault via the hosted helper. Screens use it * to route signing to /net (where the share lives) instead of a server-side ceremony. */ export const IS_NET = NET diff --git a/ui/src/components.tsx b/ui/src/components.tsx index ad97be6..db38242 100644 --- a/ui/src/components.tsx +++ b/ui/src/components.tsx @@ -4,8 +4,9 @@ import { useReveal } from './reveal' import { useI18n, useT } from './i18n' import { IS_DEMO } from './api' -/** A thin, always-visible strip on the hosted demo build (VITE_DEMO=1) so a visitor never - * mistakes the sample data for a real vault. Renders nothing outside the demo build. */ +/** A thin, always-visible strip in demo mode (runtime `?demo=1` or the VITE_DEMO fallback) so a + * visitor never mistakes the sample data for a real vault, and can leave demo mode (`?demo=0`). + * Renders nothing outside demo mode. */ export function DemoBanner() { const t = useT() if (!IS_DEMO) return null @@ -13,6 +14,7 @@ export function DemoBanner() {
DEMO {t('demo.bannerNote')} + {t('demo.bannerExit')}
) } diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index bc3c992..55967b0 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -234,6 +234,7 @@ export const en: Record = { 'demo.live': 'Live demo', 'demo.note': 'This is the cryptography itself, running live, not the everyday vault screens.', 'demo.bannerNote': 'Demonstration with sample data. No real funds, no real transactions.', + 'demo.bannerExit': 'Exit demo', 'settings.eyebrow': 'SETTINGS', 'settings.title': 'Settings', 'settings.vault': 'Vault', diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts index b6db700..f0100cb 100644 --- a/ui/src/i18n/pt-BR.ts +++ b/ui/src/i18n/pt-BR.ts @@ -234,6 +234,7 @@ export const ptBR: Record = { 'demo.live': 'Demonstração ao vivo', 'demo.note': 'Isto é a própria criptografia, rodando ao vivo, não as telas do dia a dia do cofre.', 'demo.bannerNote': 'Demonstração com dados de exemplo. Nenhum fundo real, nenhuma transação de verdade.', + 'demo.bannerExit': 'Sair da demo', 'settings.eyebrow': 'AJUSTES', 'settings.title': 'Ajustes', 'settings.vault': 'Cofre', diff --git a/ui/src/screens/Intro.tsx b/ui/src/screens/Intro.tsx index 67d82d1..e0e92c9 100644 --- a/ui/src/screens/Intro.tsx +++ b/ui/src/screens/Intro.tsx @@ -88,10 +88,11 @@ export default function Intro() {

{t('landing.h1')}

{tr('landing.sub')}

- + {/* Enter demo MODE (mock data everywhere) at runtime — one build, no VITE_DEMO (#60). */} + {t('demo.watchCta')} - + {t('landing.ctaHow')} {t('landing.ctaVaults')}