Skip to content

Commit faf3aaf

Browse files
authored
Grid Visualizer: design overhaul and UX improvements (#271)
Major design and UX overhaul of the Grid Visualizer (flow builder). Redesigned input cards, funding model section, and keyboard navigation. Incorporates PR #241 (new currencies/account types) and PR #270 (human-readable rail names). Fixes: SWAP funding mode bug, RailDropdown state drift, JIT eligibility guard, flow diagram rail labels, missing DKK/RWF flags and region labels.
1 parent 120fdd9 commit faf3aaf

27 files changed

Lines changed: 850 additions & 669 deletions

components/grid-visualizer/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ out/
77
.vercel
88
refs/
99
experiments/
10+
.env*.local

components/grid-visualizer/package-lock.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

components/grid-visualizer/src/app/globals.scss

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
:root {
88
--action-bg: rgba(17, 169, 103, 0.08);
99
--action-text: #118453;
10+
--surface-hover: var(--color-alpha-black-04);
1011
}
1112

1213
[data-theme='dark'] {
1314
--action-bg: rgba(17, 169, 103, 0.15);
1415
--action-text: #11a967;
16+
--surface-hover: var(--color-alpha-white-06);
1517
}
1618

1719
* {
@@ -61,6 +63,26 @@ body {
6163
}
6264
}
6365

66+
// Command modal height
67+
[role='dialog'] {
68+
max-height: min(588px, 70vh) !important;
69+
}
70+
71+
// base-ui Autocomplete: seamless mouse ↔ keyboard highlight handoff
72+
// Mouse mode (default): hover wins, keyboard highlight suppressed on non-hovered items
73+
[role='dialog']:not([data-keyboard-nav]) [role='option']:hover {
74+
background-color: var(--surface-hover) !important;
75+
}
76+
77+
[role='dialog']:not([data-keyboard-nav]) [role='listbox']:hover [role='option'][data-highlighted]:not(:hover) {
78+
background-color: transparent !important;
79+
}
80+
81+
// Keyboard mode: data-highlighted wins, hover suppressed on non-highlighted items
82+
[role='dialog'][data-keyboard-nav] [role='option']:hover:not([data-highlighted]) {
83+
background-color: transparent !important;
84+
}
85+
6486
// Mobile: prevent iOS zoom on input focus (requires font-size >= 16px)
6587
@media (max-width: 767px) {
6688
[role='dialog'] input {

components/grid-visualizer/src/app/page.module.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,17 @@
4949
.sidebarContent {
5050
flex: 1;
5151
display: flex;
52-
align-items: center;
53-
justify-content: center;
5452
padding: var(--spacing-4xl);
5553
overflow-y: auto;
54+
overscroll-behavior: contain;
5655
min-height: 0;
5756
}
5857

5958
.sidebarContentInner {
6059
width: 100%;
6160
display: flex;
6261
flex-direction: column;
63-
gap: var(--spacing-2xl);
62+
margin: auto 0;
6463
}
6564

6665
.sidebarFooter {
@@ -234,6 +233,7 @@
234233
// Inline "View flow and code" button in wizard view
235234
.mobileViewBtn {
236235
@include label;
236+
margin-top: 22px;
237237
display: flex;
238238
align-items: center;
239239
justify-content: center;

components/grid-visualizer/src/app/page.tsx

Lines changed: 38 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { useTheme } from '@/hooks/useTheme';
55
import { usePanelCollapse } from '@/hooks/usePanelCollapse';
66
import { Header } from '@/components/Header/Header';
77
import { InputCard, CardChevron } from '@/components/InputCard/InputCard';
8+
import { FundingModelSection } from '@/components/FundingModelSection/FundingModelSection';
89
import { RegionPicker } from '@/components/RegionPicker/RegionPicker';
9-
import { FundingToggle } from '@/components/FundingToggle/FundingToggle';
1010
import { PopularFlows } from '@/components/PopularFlows/PopularFlows';
1111
import { CurrencyPicker } from '@/components/CurrencyPicker/CurrencyPicker';
1212
import { EmptyCanvas } from '@/components/EmptyCanvas/EmptyCanvas';
@@ -17,8 +17,7 @@ import { Agentation } from 'agentation';
1717

1818
import { IconArrowLeft } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconArrowLeft';
1919
import { IconArrowRight } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconArrowRight';
20-
import { currencies } from '@/data/currencies';
21-
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
20+
import { useState, useEffect, useCallback, useRef } from 'react';
2221
import clsx from 'clsx';
2322
import styles from './page.module.scss';
2423

@@ -31,8 +30,8 @@ export default function Home() {
3130
setReceive,
3231
setSendNetwork,
3332
setReceiveNetwork,
34-
toggleSendInternal,
35-
toggleReceiveInternal,
33+
setSourceFundingMode,
34+
setSourceRail,
3635
swap,
3736
setSourceRegion,
3837
setDestRegion,
@@ -41,12 +40,10 @@ export default function Home() {
4140
closePicker,
4241
} = useFlowBuilder();
4342

44-
// Region picker state — opens after crypto source is selected.
45-
// We hold the pending selection until the user picks a region, then commit both together.
43+
// Region picker state — opened from the inline Region row on crypto cards
4644
const [regionTarget, setRegionTarget] = useState<'send' | 'receive' | null>(null);
47-
const [pendingCryptoSend, setPendingCryptoSend] = useState<ReturnType<typeof lookupCurrency>>(null);
48-
const regionCryptoCode = pendingCryptoSend?.code
49-
?? (regionTarget === 'send' ? state.send?.code : undefined);
45+
const regionCryptoCode = regionTarget === 'send' ? state.send?.code : undefined;
46+
5047
const [showAgentation, setShowAgentation] = useState(false);
5148
const [isEmbed, setIsEmbed] = useState(false);
5249
useEffect(() => {
@@ -90,15 +87,13 @@ export default function Home() {
9087

9188
const { theme, setTheme } = useTheme();
9289

93-
// Theme change handler: also posts to parent when embedded
9490
const handleThemeChange = useCallback((t: 'light' | 'dark') => {
9591
setTheme(t);
9692
if (isEmbed) {
9793
window.parent.postMessage({ type: 'theme-sync', theme: t }, '*');
9894
}
9995
}, [setTheme, isEmbed]);
10096

101-
// Listen for theme sync from parent frame when embedded
10297
useEffect(() => {
10398
if (!isEmbed) return;
10499
const handler = (e: MessageEvent) => {
@@ -107,7 +102,6 @@ export default function Home() {
107102
}
108103
};
109104
window.addEventListener('message', handler);
110-
// Tell parent we're ready to receive theme
111105
window.parent.postMessage({ type: 'theme-request' }, '*');
112106
return () => window.removeEventListener('message', handler);
113107
}, [isEmbed, setTheme]);
@@ -122,39 +116,14 @@ export default function Home() {
122116
: (isDark ? style.getPropertyValue('--color-gray-950') : style.getPropertyValue('--surface-primary'));
123117
const trimmed = color.trim();
124118
if (!trimmed) return;
125-
// Update both media-specific meta tags so the correct one applies
126119
document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]').forEach(meta => {
127120
meta.content = trimmed;
128121
});
129122
}, [mobileView, theme]);
123+
130124
const { flowExpanded, codeExpanded, toggleFlow, toggleCode } =
131125
usePanelCollapse();
132126

133-
const [overrideFunding, setOverrideFunding] = useState<'jit' | 'pre-funded' | null>(null);
134-
135-
const effectiveFundingModel = overrideFunding ?? fundingModel;
136-
137-
const handleFundingToggle = useCallback(() => {
138-
setOverrideFunding((prev) => {
139-
if (prev === null) {
140-
// First toggle: flip from inferred value
141-
return fundingModel === 'jit' ? 'pre-funded' : 'jit';
142-
}
143-
return prev === 'jit' ? 'pre-funded' : 'jit';
144-
});
145-
}, [fundingModel]);
146-
147-
// Reset funding override when source currency changes (not on internal toggle — it hides anyway)
148-
useEffect(() => {
149-
setOverrideFunding(null);
150-
}, [state.send?.code]);
151-
152-
const sourceInstantRails = useMemo(() => {
153-
if (!state.send || state.send.type !== 'fiat') return [];
154-
const fiat = currencies.find((c) => c.code === state.send!.code);
155-
return fiat?.instantRails ?? [];
156-
}, [state.send]);
157-
158127
return (
159128
<main className={styles.layout} data-mobile-view={mobileView}>
160129
{/* Left sidebar */}
@@ -182,18 +151,20 @@ export default function Home() {
182151
)}
183152

184153
<div className={styles.sidebarContent}>
185-
<div className={styles.sidebarContentInner}>
154+
<div className={styles.sidebarContentInner}>
186155
<Header />
187156

188-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xs)' }}>
189-
<div style={{ display: 'flex', flexDirection: 'column' }}>
157+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
158+
<div style={{ display: 'flex', flexDirection: 'column', isolation: 'isolate' }}>
190159
<InputCard
191160
label="Source"
192161
selection={state.send}
193162
region={state.sourceRegion}
163+
rail={state.sourceRail}
194164
onCardClick={() => openPicker('send')}
195-
onToggleInternal={toggleSendInternal}
196165
onNetworkChange={setSendNetwork}
166+
onRailChange={setSourceRail}
167+
onRegionClick={() => setRegionTarget('send')}
197168
/>
198169
<div style={{ position: 'relative', height: '8px' }}>
199170
<CardChevron onSwap={state.send || state.receive ? swap : undefined} />
@@ -202,18 +173,15 @@ export default function Home() {
202173
label="Destination"
203174
selection={state.receive}
204175
onCardClick={() => openPicker('receive')}
205-
onToggleInternal={toggleReceiveInternal}
206176
onNetworkChange={setReceiveNetwork}
207177
/>
208178
</div>
209179

210-
{state.send && (
211-
<FundingToggle
212-
fundingModel={effectiveFundingModel}
213-
jitEligible={state.send.jitEligible}
214-
isInternal={state.send.isInternal}
215-
onToggle={handleFundingToggle}
216-
instantRails={sourceInstantRails}
180+
{state.send && state.receive && (
181+
<FundingModelSection
182+
source={state.send}
183+
selectedMode={state.sourceFundingMode}
184+
onModeChange={setSourceFundingMode}
217185
/>
218186
)}
219187
</div>
@@ -239,12 +207,14 @@ export default function Home() {
239207
</button>
240208
)}
241209

242-
<PopularFlows onSelect={(sendCode, receiveCode) => {
243-
const s = lookupCurrency(sendCode);
244-
const r = lookupCurrency(receiveCode);
245-
if (s) setSend(s);
246-
if (r) setReceive(r);
247-
}} />
210+
<div style={{ marginTop: isComplete ? '10px' : '24px' }}>
211+
<PopularFlows onSelect={(sendCode, receiveCode) => {
212+
const s = lookupCurrency(sendCode);
213+
const r = lookupCurrency(receiveCode);
214+
if (s) setSend(s);
215+
if (r) setReceive(r);
216+
}} />
217+
</div>
248218
</div>
249219
</div>
250220

@@ -264,13 +234,15 @@ export default function Home() {
264234
receive={state.receive!}
265235
sourceRegion={state.sourceRegion}
266236
destRegion={state.destRegion}
237+
sourceRail={state.sourceRail}
267238
expanded={flowExpanded}
268239
onToggle={toggleFlow}
269240
/>
270241
<CodePanel
271242
send={state.send!}
272243
receive={state.receive!}
273-
fundingModel={effectiveFundingModel}
244+
fundingModel={fundingModel}
245+
sourceRail={state.sourceRail}
274246
audience={state.audience}
275247
onAudienceChange={setAudience}
276248
expanded={codeExpanded}
@@ -287,13 +259,7 @@ export default function Home() {
287259
onSelect={(sel) => {
288260
const target = state.pickerTarget;
289261
if (target === 'send') {
290-
if (sel.type === 'crypto') {
291-
// Don't commit yet — need region first
292-
setPendingCryptoSend(sel);
293-
setRegionTarget('send');
294-
} else {
295-
setSend(sel);
296-
}
262+
setSend(sel);
297263
} else {
298264
setReceive(sel);
299265
}
@@ -307,22 +273,17 @@ export default function Home() {
307273
}
308274
/>
309275

310-
{/* Region picker modal — opens after crypto source is selected */}
276+
{/* Region picker modal — opens from crypto card Region row */}
311277
<RegionPicker
312278
open={regionTarget !== null}
313279
cryptoCode={regionCryptoCode}
314-
onClose={() => {
315-
// User dismissed without picking — discard pending selection
316-
setPendingCryptoSend(null);
317-
setRegionTarget(null);
318-
}}
280+
onClose={() => setRegionTarget(null)}
319281
onSelect={(regionCode) => {
320-
// Commit the pending crypto selection + region together
321-
if (pendingCryptoSend) {
322-
setSend(pendingCryptoSend);
282+
if (regionTarget === 'send') {
283+
setSourceRegion(regionCode);
284+
} else if (regionTarget === 'receive') {
285+
setDestRegion(regionCode);
323286
}
324-
setSourceRegion(regionCode);
325-
setPendingCryptoSend(null);
326287
setRegionTarget(null);
327288
}}
328289
/>
@@ -338,7 +299,7 @@ export default function Home() {
338299
</div>
339300
)}
340301

341-
{/* Mobile results view: floating pill — hides on scroll down, shows on scroll up */}
302+
{/* Mobile results view: floating pill */}
342303
{isComplete && mobileView === 'results' && (
343304
<button
344305
className={clsx(

0 commit comments

Comments
 (0)