Global Accounts interactive demo ("See it live")#527
Conversation
Interactive, embeddable wallet demo for the Grid docs — the neobank analog of
the Flow Builder. New standalone Next.js app (components/grid-wallet-demo) that
the docs embed in an iframe with light/dark theme sync, mirroring how
grid-visualizer powers /flow-builder.
- Same shell + design system as the Flow Builder (@lightsparkdev/origin,
sidebar + canvas + CodePanel-style API log, central-icons, squircle).
- Playground model: pick a use case + sign-in method, then drive a real wallet
on a phone (create account, add money, send, cash out, issue a card, tap to
pay) by clicking the phone itself; the exact Grid API calls render alongside.
- Phone wallet adapted from the bread neobank app; Robinhood-style glass card
with a cinematic reveal.
- Scripted happy path (no live sandbox calls), like the Flow Builder.
Docs wiring (mintlify/): new global-accounts/demo.mdx ("See it live", under
Introduction), docs.json nav + chrome-hide CSS, style.css full-bleed iframe.
Deployment + designer handoff notes in components/grid-wallet-demo/HANDOFF.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
Greptile SummaryThis PR introduces a new standalone Next.js 14 interactive demo for Global Accounts (
Confidence Score: 3/5Merging is safe for a not-live-yet preview, but the demo has several functional issues that would frustrate real visitors before fixes land. The auth library permanently caches rejected GIS/Apple script loads, and the Google sign-in flow can freeze the entire demo from a missing client ID or a blocked CDN. These pair with the previously-flagged balance-guard holes and the total-balance-vs-delta display bug, together affecting most interactive paths a visitor would try. components/grid-wallet-demo/src/lib/auth.ts (stale rejection cache), components/grid-wallet-demo/src/data/actions.ts (balance guards), components/grid-wallet-demo/src/components/Phone.tsx (Google auth freeze and change-row display)
|
| Filename | Overview |
|---|---|
| components/grid-wallet-demo/src/lib/auth.ts | Real WebAuthn/GIS/Apple auth ceremonies; gisPromise/applePromise are module-level singletons that permanently cache rejected promises, blocking those sign-in paths after one network failure. |
| components/grid-wallet-demo/src/app/page.tsx | Main orchestration: interactive prompts, wallet state machine, action dispatch. runSend/runWithdraw guard with Math.max(0,...) prevents negative balance in state, but action available guards in actions.ts still allow firing when balance is less than deduction amount. |
| components/grid-wallet-demo/src/data/actions.ts | Action guard definitions; send/withdraw guards allow firing when balance is less than deduction (previously flagged), tap has no minimum-balance guard (previously flagged). |
| components/grid-wallet-demo/src/components/Phone.tsx | Phone UI; changeRow displays total balance as today's delta (previously flagged), Google sign-in freezes demo when NEXT_PUBLIC_GOOGLE_CLIENT_ID is unset (previously flagged). |
| mintlify/global-accounts/demo.mdx | Docs page embedding the iframe; postMessage handler processes theme-sync without validating e.origin (previously flagged). |
| components/grid-wallet-demo/next.config.mjs | Next.js config; ignoreBuildErrors:true swallows all TS errors (previously flagged); CSP frame-ancestors correctly enumerates allowed embedding origins. |
| components/grid-wallet-demo/src/hooks/useTheme.ts | Theme sync hook; posts theme-request and theme-sync messages to * instead of narrowing to the known docs origin. |
Sequence Diagram
sequenceDiagram
participant User
participant Docs as docs.lightspark.com
participant IFrame as grid-wallet-demo iframe
participant Phone as Phone UI
participant API as API Panel
User->>Docs: Loads /global-accounts/demo
Docs->>IFrame: "Embed /?embed=true&theme=dark"
IFrame->>Docs: postMessage theme-request to any origin
Docs->>IFrame: postMessage theme-sync theme
User->>IFrame: Picks persona + sign-in method
User->>Phone: Clicks Sign in
Phone->>IFrame: onAction create
IFrame->>Phone: Show auth screen
Phone->>IFrame: Auth resolved
IFrame->>API: Push credential and account API calls
IFrame->>Phone: wallet.created true
User->>Phone: Clicks Add money
Phone->>IFrame: onAction add
IFrame->>Phone: AmountEntryScreen
Phone->>IFrame: submitAmount dollars
IFrame->>API: Push addMoneyCalls cents
IFrame->>Phone: Update balance
User->>Phone: Clicks Issue a card
Phone->>IFrame: onAction card
IFrame->>Phone: CardReveal animation
User->>Phone: Clicks Tap to pay
Phone->>IFrame: onAction tap
IFrame->>API: Push transaction API call
IFrame->>Phone: TapScreen activated
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
components/grid-wallet-demo/src/lib/auth.ts:11-23
**Rejected GIS promise is permanently cached, permanently blocking Google sign-in**
`gisPromise` is a module-level singleton. If `loadGis()` rejects (GIS script blocked by an ad blocker, network hiccup, etc.), `gisPromise` is set to a rejected `Promise` — which is truthy. Every subsequent call to `loadGis()` hits the `if (gisPromise) return gisPromise` guard and immediately returns the same rejected promise, so there is no retry possible for the rest of the session.
In `GoogleSignInScreen` the rejection is swallowed by `.catch(() => {})`, leaving the GIS button never rendered. The `googlePrompt` promise created in `promptGoogle` never resolves, `running` stays `true`, and the demo locks up with no escape. Ad blockers blocking `accounts.google.com` are extremely common on developer-audience docs pages, making this trigger realistic. The same pattern affects `applePromise`/`loadAppleAuth`.
### Issue 2 of 2
components/grid-wallet-demo/src/hooks/useTheme.ts:49-52
The iframe posts messages to `'*'`, meaning any embedding origin receives them. The outbound `theme-sync` message (line 63) also uses `'*'`. Since the `Content-Security-Policy` in `next.config.mjs` already enumerates the exact set of allowed embedding origins, the `targetOrigin` here can be narrowed to `https://docs.lightspark.com` (and `http://localhost:*` for local dev) to avoid broadcasting to an arbitrary parent.
```suggestion
// Ask the parent (if embedded) for the current theme.
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'theme-request' }, 'https://docs.lightspark.com');
}
```
Reviews (4): Last reviewed commit: "Enable Apple sign-in in Global Accounts ..." | Re-trigger Greptile
| id: 'tap', | ||
| label: 'Tap to pay', | ||
| desc: 'Spend at a store', | ||
| icon: 'tap', | ||
| available: (s) => s.hasCard, | ||
| }, |
There was a problem hiding this comment.
tap action can produce a negative wallet balance
The tap action's available guard only checks s.hasCard — there is no minimum balance requirement. A user can: (1) create an account, (2) immediately issue a card (no funds required), (3) tap → balance becomes -$7.32. With funds, repeated tapping also drives the balance negative because the same guard allows unlimited taps. The UI then displays a negative balance in the demo, which misrepresents what the Grid API would actually permit.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-demo/src/data/actions.ts
Line: 88-93
Comment:
**`tap` action can produce a negative wallet balance**
The `tap` action's `available` guard only checks `s.hasCard` — there is no minimum balance requirement. A user can: (1) create an account, (2) immediately issue a card (no funds required), (3) tap → balance becomes -$7.32. With funds, repeated tapping also drives the balance negative because the same guard allows unlimited taps. The UI then displays a negative balance in the demo, which misrepresents what the Grid API would actually permit.
How can I resolve this? If you propose a fix, please make it concise.| {hasFunds && ( | ||
| <div className={styles.changeRow}> | ||
| <span className={styles.deltaUp}>+ {phone.balance}</span> | ||
| <span className={styles.deltaChip}>Today</span> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
changeRow displays total balance instead of the day's change amount
phone.balance holds the total wallet balance, not the delta from the last transaction. After a second add action the row would render + $10,000.00 · Today even though only $5,000 was added, misleading visitors about what the display represents.
| {hasFunds && ( | |
| <div className={styles.changeRow}> | |
| <span className={styles.deltaUp}>+ {phone.balance}</span> | |
| <span className={styles.deltaChip}>Today</span> | |
| </div> | |
| )} | |
| {hasFunds && ( | |
| <div className={styles.changeRow}> | |
| <span className={styles.deltaUp}>+ $5,000.00</span> | |
| <span className={styles.deltaChip}>Today</span> | |
| </div> | |
| )} |
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-demo/src/components/Phone.tsx
Line: 238-243
Comment:
**`changeRow` displays total balance instead of the day's change amount**
`phone.balance` holds the *total* wallet balance, not the delta from the last transaction. After a second `add` action the row would render `+ $10,000.00 · Today` even though only $5,000 was added, misleading visitors about what the display represents.
```suggestion
{hasFunds && (
<div className={styles.changeRow}>
<span className={styles.deltaUp}>+ $5,000.00</span>
<span className={styles.deltaChip}>Today</span>
</div>
)}
```
How can I resolve this? If you propose a fix, please make it concise.| if (e.data && e.data.type === 'theme-request') { | ||
| sendTheme(); | ||
| return; | ||
| } | ||
| if (e.data && e.data.type === 'theme-sync') { | ||
| const isDark = document.documentElement.classList.contains('dark'); | ||
| const wantsDark = e.data.theme === 'dark'; | ||
| if (isDark !== wantsDark) { | ||
| ignoreNextMutation = true; | ||
| document.documentElement.classList.toggle('dark'); | ||
| } | ||
| } | ||
| }; | ||
| window.addEventListener('message', handleMessage); | ||
|
|
There was a problem hiding this comment.
postMessage handler trusts messages from any origin
handleMessage processes both theme-request and theme-sync events without validating e.origin. Any page that holds a reference to the docs window can send { type: 'theme-sync', theme: 'dark' } and toggle the dark-mode class on the HTML element. The fix is a simple origin allowlist — e.g. skip messages unless e.origin === window.location.origin || e.origin.endsWith('.lightspark.com').
Prompt To Fix With AI
This is a comment left during a code review.
Path: mintlify/global-accounts/demo.mdx
Line: 29-43
Comment:
**`postMessage` handler trusts messages from any origin**
`handleMessage` processes both `theme-request` and `theme-sync` events without validating `e.origin`. Any page that holds a reference to the docs window can send `{ type: 'theme-sync', theme: 'dark' }` and toggle the dark-mode class on the HTML element. The fix is a simple origin allowlist — e.g. skip messages unless `e.origin === window.location.origin || e.origin.endsWith('.lightspark.com')`.
How can I resolve this? If you propose a fix, please make it concise.| typescript: { | ||
| // Origin is source-linked without its own node_modules, | ||
| // so its transitive type imports can't resolve from ../origin | ||
| ignoreBuildErrors: true, | ||
| }, |
There was a problem hiding this comment.
TypeScript build errors are silently swallowed
ignoreBuildErrors: true means next build will succeed even when there are type errors. The comment explains the current need (transitive imports from the local @lightsparkdev/origin source link), but it also masks any new type mistakes in this project's own code. Consider scoping the ignore to only the problematic external package paths once the origin package situation is resolved, or at minimum running tsc --noEmit in CI as a separate check so regressions don't go unnoticed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-demo/next.config.mjs
Line: 10-14
Comment:
**TypeScript build errors are silently swallowed**
`ignoreBuildErrors: true` means `next build` will succeed even when there are type errors. The comment explains the current need (transitive imports from the local `@lightsparkdev/origin` source link), but it also masks any new type mistakes in this project's own code. Consider scoping the ignore to only the problematic external package paths once the origin package situation is resolved, or at minimum running `tsc --noEmit` in CI as a separate check so regressions don't go unnoticed.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
- demo.mdx: remove JS line comment that broke Mintlify's MDX parse (rendered the page blank) and add phone icon to "See it live" - add phone.svg sidebar icon - vendor Suisse Intl fonts into public/fonts so Origin's @font-face resolves (was 404ing, falling back to system fonts) - flow.ts: keep Google/Apple capitalized in the sign-in CTA - Sidebar.tsx: mark Phone (SMS) sign-in as "Soon" Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| available: (s) => s.created && s.balanceCents > 0, | ||
| }, | ||
| { | ||
| id: 'withdraw', | ||
| label: 'Withdraw', | ||
| desc: 'Cash out to a bank', | ||
| icon: 'bank', | ||
| available: (s) => s.created && s.balanceCents > 0, |
There was a problem hiding this comment.
send and withdraw guards allow negative balance
Both guards check only s.balanceCents > 0, but each deducts a fixed amount that can exceed the available balance. A visitor who mixes actions can reach a small positive balance that still enables these buttons: add ($5,000) → send ×19 ($4,750 total) → balance $250 → withdraw ($200) → balance $50 → send is still available (50 > 0) and deducts $25,000 cents → display shows −$249.50. Like the already-noted tap issue, the fix is to require s.balanceCents >= SEND and s.balanceCents >= WITHDRAW in the respective guards.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-demo/src/data/actions.ts
Line: 70-77
Comment:
**`send` and `withdraw` guards allow negative balance**
Both guards check only `s.balanceCents > 0`, but each deducts a fixed amount that can exceed the available balance. A visitor who mixes actions can reach a small positive balance that still enables these buttons: `add` ($5,000) → `send` ×19 ($4,750 total) → balance $250 → `withdraw` ($200) → balance $50 → `send` is still available (`50 > 0`) and deducts $25,000 cents → display shows **−$249.50**. Like the already-noted `tap` issue, the fix is to require `s.balanceCents >= SEND` and `s.balanceCents >= WITHDRAW` in the respective guards.
How can I resolve this? If you propose a fix, please make it concise.| useEffect(() => { | ||
| let cancelled = false; | ||
| loadGis() | ||
| .then(() => { | ||
| if (cancelled || !containerRef.current) return; | ||
| const google = (window as any).google; | ||
| const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; | ||
| google.accounts.id.initialize({ | ||
| client_id: clientId, | ||
| nonce, | ||
| callback: (resp: { credential?: string }) => { | ||
| if (resp?.credential && !fired.current) { | ||
| fired.current = true; | ||
| onCredential(resp.credential); | ||
| } | ||
| }, | ||
| }); | ||
| google.accounts.id.renderButton(containerRef.current, { | ||
| theme: 'filled_blue', | ||
| size: 'large', | ||
| type: 'standard', | ||
| text: 'continue_with', | ||
| shape: 'pill', | ||
| }); | ||
| }) | ||
| .catch(() => {}); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [nonce, onCredential]); |
There was a problem hiding this comment.
Google OAuth silently freezes the entire demo
google.accounts.id.initialize({ client_id: clientId, ... }) is called with clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID. If that env var is unset, GIS either throws or renders a non-functional button; both outcomes are swallowed by .catch(() => {}). The promptGoogle promise returned by authenticate() then never resolves — running stays true and, because wallet.created is still false at this stage, neither sidebar action buttons nor the "Start over" button are accessible. The user has no escape short of reloading the page. A guard that rejects the googlePrompt early (or surfaces a visible error) when clientId is falsy would prevent the lock-up.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-demo/src/components/Phone.tsx
Line: 362-391
Comment:
**Google OAuth silently freezes the entire demo**
`google.accounts.id.initialize({ client_id: clientId, ... })` is called with `clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID`. If that env var is unset, GIS either throws or renders a non-functional button; both outcomes are swallowed by `.catch(() => {})`. The `promptGoogle` promise returned by `authenticate()` then never resolves — `running` stays `true` and, because `wallet.created` is still `false` at this stage, neither sidebar action buttons nor the "Start over" button are accessible. The user has no escape short of reloading the page. A guard that rejects the `googlePrompt` early (or surfaces a visible error) when `clientId` is falsy would prevent the lock-up.
How can I resolve this? If you propose a fix, please make it concise.| let gisPromise: Promise<void> | null = null; | ||
| export function loadGis(): Promise<void> { | ||
| if (gisPromise) return gisPromise; | ||
| gisPromise = new Promise((resolve, reject) => { | ||
| if ((window as any).google?.accounts?.id) return resolve(); | ||
| const s = document.createElement('script'); | ||
| s.src = 'https://accounts.google.com/gsi/client'; | ||
| s.async = true; | ||
| s.onload = () => resolve(); | ||
| s.onerror = () => reject(new Error('Failed to load Google Identity Services.')); | ||
| document.head.appendChild(s); | ||
| }); | ||
| return gisPromise; |
There was a problem hiding this comment.
Rejected GIS promise is permanently cached, permanently blocking Google sign-in
gisPromise is a module-level singleton. If loadGis() rejects (GIS script blocked by an ad blocker, network hiccup, etc.), gisPromise is set to a rejected Promise — which is truthy. Every subsequent call to loadGis() hits the if (gisPromise) return gisPromise guard and immediately returns the same rejected promise, so there is no retry possible for the rest of the session.
In GoogleSignInScreen the rejection is swallowed by .catch(() => {}), leaving the GIS button never rendered. The googlePrompt promise created in promptGoogle never resolves, running stays true, and the demo locks up with no escape. Ad blockers blocking accounts.google.com are extremely common on developer-audience docs pages, making this trigger realistic. The same pattern affects applePromise/loadAppleAuth.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-wallet-demo/src/lib/auth.ts
Line: 11-23
Comment:
**Rejected GIS promise is permanently cached, permanently blocking Google sign-in**
`gisPromise` is a module-level singleton. If `loadGis()` rejects (GIS script blocked by an ad blocker, network hiccup, etc.), `gisPromise` is set to a rejected `Promise` — which is truthy. Every subsequent call to `loadGis()` hits the `if (gisPromise) return gisPromise` guard and immediately returns the same rejected promise, so there is no retry possible for the rest of the session.
In `GoogleSignInScreen` the rejection is swallowed by `.catch(() => {})`, leaving the GIS button never rendered. The `googlePrompt` promise created in `promptGoogle` never resolves, `running` stays `true`, and the demo locks up with no escape. Ad blockers blocking `accounts.google.com` are extremely common on developer-audience docs pages, making this trigger realistic. The same pattern affects `applePromise`/`loadAppleAuth`.
How can I resolve this? If you propose a fix, please make it concise.
What this is
A first, working pass at an interactive Global Accounts demo for the docs — the neobank/wallet analog of the Flow Builder. It lives at
docs.lightspark.com/global-accounts/demo("See it live", directly under Introduction).A visitor picks a use case (Fintech/Neobank live; Social + Marketplace stubbed "Soon") and a sign-in method (Passkey / Google / Apple / Email / Phone), then drives a real wallet on a phone — create account → add money → send → cash out → issue a card → tap to pay — by clicking the phone itself, while the exact Grid API calls render in a panel beside it, in sync.
How it's wired (same pattern as the Flow Builder)
components/grid-wallet-demo, embedded by the docs in an<iframe>with light/darkpostMessagetheme sync — identical to howgrid-visualizerpowers/flow-builder.@lightsparkdev/origintokens,sidebar (475px) + canvas + CodePanel-style API log,central-icons, squircle corners, theEmptyCanvasdotted background, theHeader/Footer.breadneobank app; the card is a Robinhood-style dark glass card with a cinematic reveal animation.Docs changes (
mintlify/)global-accounts/demo.mdx— new page (mode: "custom"), iframe + theme-sync, auto-targetslocalhost:4000locally /grid-wallet-demo.vercel.appin prod.docs.json— nav entry under Overview (afterindex) + chrome-hide CSS for#wallet-demo-container.style.css— full-bleed iframe sizing (mirrors#flow-builder-container).Run locally
components/grid-wallet-demo(buildnpm run build, install--ignore-scriptsfor the central-icons license hook, same as grid-visualizer). The docs page is blank until this exists. Swap the URL indemo.mdxif different.What's faked (scripted, like the Flow Builder)
src/data/actions.ts.Designer focus areas (see HANDOFF.md)
The phone is ~90% of the craft (
Phone.tsx/Phone.module.scss): per-screen spacing/type, the card art + reveal timing, native-feeling auth sheets, per-persona theming, and a light-mode pass.🤖 Generated with Claude Code