Grid Visualizer: design overhaul and UX improvements#271
Conversation
…l UX - Redesign InputCard with rail/network dropdowns and refined hover/border styles - Replace FundingToggle with new FundingModelSection selector group - Add useCommandNav hook for unified mouse/keyboard highlight in Command modal - Animate header collapse when source or destination is filled - Fix CardChevron z-index regression with isolation stacking context - Show actual rail names in flow connector labels - Adjust spacing, gaps, and PopularFlows layout Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
…l source fix - JIT subtitle now shows "Funded from an outside wallet" (crypto) or "Funded from an outside bank account" (fiat) - Remove incorrect "Fund internal account" step for External funding mode; quote source now correctly references ExternalAccount - Add instructional "Trigger the payment" step for JIT flows with dynamic rail text based on user's selected rail - Wire fiat rail selection from InputCard through to flow builder state so the trigger step updates when the user changes rails Made-with: Cursor
…-updates Made-with: Cursor # Conflicts: # components/grid-visualizer/src/lib/code-generator.ts
Made-with: Cursor
Greptile SummaryThis PR delivers a significant design and UX overhaul of the Grid Visualizer: the old Key issues found:
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| components/grid-visualizer/src/hooks/useFlowBuilder.ts | Replaces jitFunding boolean and toggleInternal helpers with sourceFundingMode enum and sourceRail. SWAP now correctly resets both. fundingModel derivation simplified but loses the jitEligible guard (now delegated to FundingModelSection). SET_SOURCE_FUNDING_MODE doesn't clear sourceRail when switching to internal mode, leaving a stale rail value in state. |
| components/grid-visualizer/src/components/InputCard/InputCard.tsx | Replaces account-type tabs and static badges with property rows (Rail, Network, Region). New RailDropdown partially syncs from currentRail prop via useEffect. Rail row has no isInternal guard — it renders for internal fiat sources, which don't use rails. |
| components/grid-visualizer/src/app/page.tsx | Simplified state machine: removes overrideFunding, pendingCryptoSend, and the two-step crypto selection flow. FundingModelSection is now gated on both state.send && state.receive. A dead code branch (regionTarget === 'receive') exists in the RegionPicker onSelect handler as there is no UI path to set it. |
| components/grid-visualizer/src/lib/code-generator.ts | Adds isInstructional step type and sourceRail parameter. External source registration now correctly skips crypto and JIT sources. New JIT "Trigger the payment" instructional step is correctly filtered from copy-all, but formatCurl has no guard for instructional steps and would produce malformed curl if called directly. |
| components/grid-visualizer/src/hooks/useCommandNav.ts | New hook unifying keyboard and mouse highlight tracking for the Command modal. Logic is well-structured. Minor race: the requestAnimationFrame callback can re-attach the MutationObserver after cleanup if open changes before the first frame fires. |
| components/grid-visualizer/src/components/FundingModelSection/FundingModelSection.tsx | New component replacing FundingToggle. Correctly gates the "Just-in-time" option on source.jitEligible for fiat sources and shows only realtime/internal options for crypto. Animation using motion/react is well-implemented. |
| components/grid-visualizer/src/lib/flow-path.ts | Adds sourceRail parameter to buildFlowPath and uses it for the source connector label, resolving the prior inconsistency with the code panel. Destination connector label still derives rail from currency defaults (no user selection for destination rail). |
| components/grid-visualizer/src/components/CurrencyPicker/CurrencyPicker.tsx | Integrates useCommandNav for keyboard navigation. Currencies are now sorted alphabetically and popular items preserve insertion order. Popular list updated to include USDT and remove BTC/MXN/NGN. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A([User opens Grid Visualizer]) --> B[Select Source Currency]
B --> C{Source type?}
C -- Fiat --> D[SET_SEND: sourceFundingMode=external\nsourceRail=defaultRail]
C -- Crypto --> E[SET_SEND: sourceFundingMode=realtime\nsourceRegion=USD default]
D --> F[InputCard shows Rail row]
E --> G[InputCard shows Network + Region rows]
F --> H[Select Destination Currency]
G --> H
H --> I[FundingModelSection visible\nsend AND receive both set]
I --> J{Select funding mode}
J -- External --> K[fundingModel=pre-funded\nRail dropdown active]
J -- Just-in-time --> L[fundingModel=jit\nInstructional step added]
J -- Internal --> M[fundingModel=pre-funded\nsend.isInternal=true\nRail row still shown ⚠️]
K --> N[buildFlowPath + generateSteps]
L --> N
M --> N
N --> O[FlowPanel renders diagram\nwith sourceRail connector label]
N --> P[CodePanel renders API steps\nfilters isInstructional from copy-all]
Comments Outside Diff (1)
-
components/grid-visualizer/src/lib/code-generator.ts, line 331-368 (link)formatCurlproduces malformed output for instructional stepsThe new
isInstructionalstep type setsmethod: ''andendpoint: ''. IfformatCurlis ever called on such a step (e.g., from a test or future code path), it produces:curl -X \ '' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"The UI currently guards against this (
CodeSteprendersInstructionalBlockandCopyAllButtonfilters them out), but the function itself has no defensive guard. Adding an early return for instructional steps would make the function self-documenting and safe for direct callers:export function formatCurl(step: ApiStep): string { if (step.isInstructional) return ''; // instructional steps have no HTTP call ... }
Prompt To Fix With AI
This is a comment left during a code review. Path: components/grid-visualizer/src/lib/code-generator.ts Line: 331-368 Comment: **`formatCurl` produces malformed output for instructional steps** The new `isInstructional` step type sets `method: ''` and `endpoint: ''`. If `formatCurl` is ever called on such a step (e.g., from a test or future code path), it produces: ``` curl -X \ '' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` The UI currently guards against this (`CodeStep` renders `InstructionalBlock` and `CopyAllButton` filters them out), but the function itself has no defensive guard. Adding an early return for instructional steps would make the function self-documenting and safe for direct callers: ```ts export function formatCurl(step: ApiStep): string { if (step.isInstructional) return ''; // instructional steps have no HTTP call ... } ``` How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: components/grid-visualizer/src/app/page.tsx
Line: 273-280
Comment:
**`regionTarget === 'receive'` branch is unreachable dead code**
The `else if (regionTarget === 'receive')` branch calls `setDestRegion(regionCode)`, but `regionTarget` can only ever be set to `'send'` — the Destination `InputCard` receives no `onRegionClick` prop and no `region` prop, so there is no UI path that sets `regionTarget` to `'receive'`.
This also means crypto destinations never get a user-selectable region. The `SET_RECEIVE` reducer already defaults non-BTC crypto destinations to `'USD'`, but the user cannot change it. If destination-region picking is intended for a future milestone, a comment clarifying that would avoid confusion; if it is not, the branch and the `setDestRegion` call below can be removed.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: components/grid-visualizer/src/lib/code-generator.ts
Line: 331-368
Comment:
**`formatCurl` produces malformed output for instructional steps**
The new `isInstructional` step type sets `method: ''` and `endpoint: ''`. If `formatCurl` is ever called on such a step (e.g., from a test or future code path), it produces:
```
curl -X \
'' \
-u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```
The UI currently guards against this (`CodeStep` renders `InstructionalBlock` and `CopyAllButton` filters them out), but the function itself has no defensive guard. Adding an early return for instructional steps would make the function self-documenting and safe for direct callers:
```ts
export function formatCurl(step: ApiStep): string {
if (step.isInstructional) return ''; // instructional steps have no HTTP call
...
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: components/grid-visualizer/src/hooks/useCommandNav.ts
Line: 90-105
Comment:
**RAF callback can re-attach observer after cleanup**
The `requestAnimationFrame` callback captures `observer` by closure, but its cleanup call (`observer.disconnect()`) runs immediately when `open` becomes `false`. If the component unmounts or `open` flips before the first animation frame fires, the cleanup runs first, then the RAF callback executes and calls `observer.observe(listbox, ...)` — re-attaching the observer after it was already disconnected.
This is an unlikely race in practice (the dialog animation would need to complete in under one frame), but a cancelled-frame guard makes the intent clearer:
```ts
let cancelled = false;
requestAnimationFrame(() => {
if (cancelled) return;
const listbox = getDialog()?.querySelector('[role="listbox"]');
if (listbox) {
observer.observe(listbox, { childList: true, subtree: true });
}
const opts = getOptions();
if (opts.length) setHighlight(opts[0]);
});
return () => {
cancelled = true;
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('pointerover', onPointerOver, true);
document.removeEventListener('mousemove', onMouseMove, true);
observer.disconnect();
};
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: components/grid-visualizer/src/components/InputCard/InputCard.tsx
Line: 386-401
Comment:
**Rail row shown for internal fiat sources**
The Rail row (both the dropdown and the static variant) is rendered whenever `selection.type === 'fiat'`, with no guard for `selection.isInternal`. When `FundingModelSection` sets the mode to `'internal'`, `SET_SOURCE_FUNDING_MODE` marks `send.isInternal = true`, but `InputCard` still shows the Rail UI — even though internal Grid accounts don't use external payment rails.
Both conditions on lines 386 and 394 need an additional `&& !selection.isInternal` check to suppress the Rail row when the account is internal.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: b8a2213
When swapping a crypto source with a fiat destination, sourceFundingMode remained 'realtime' instead of being recalculated for the new source type. Made-with: Cursor
Make RailDropdown a controlled component by accepting currentRail prop, preventing visual drift when the parent resets the rail via funding mode or swap changes. Made-with: Cursor
|
@greptile i made the fixes and i resolved one of your comments could you review again |
Made-with: Cursor
Made-with: Cursor
Thread sourceRail from state through FlowPanel to buildFlowPath so the diagram shows the user-selected rail instead of always defaulting to the first instant rail. Also remove unused getRailLabel function. Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
| } | ||
| /> | ||
|
|
||
| {/* Region picker modal — opens after crypto source is selected */} | ||
| {/* Region picker modal — opens from crypto card Region row */} | ||
| <RegionPicker | ||
| open={regionTarget !== null} | ||
| cryptoCode={regionCryptoCode} | ||
| onClose={() => { | ||
| // User dismissed without picking — discard pending selection | ||
| setPendingCryptoSend(null); | ||
| setRegionTarget(null); | ||
| }} | ||
| onClose={() => setRegionTarget(null)} |
There was a problem hiding this comment.
regionTarget === 'receive' branch is unreachable dead code
The else if (regionTarget === 'receive') branch calls setDestRegion(regionCode), but regionTarget can only ever be set to 'send' — the Destination InputCard receives no onRegionClick prop and no region prop, so there is no UI path that sets regionTarget to 'receive'.
This also means crypto destinations never get a user-selectable region. The SET_RECEIVE reducer already defaults non-BTC crypto destinations to 'USD', but the user cannot change it. If destination-region picking is intended for a future milestone, a comment clarifying that would avoid confusion; if it is not, the branch and the setDestRegion call below can be removed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-visualizer/src/app/page.tsx
Line: 273-280
Comment:
**`regionTarget === 'receive'` branch is unreachable dead code**
The `else if (regionTarget === 'receive')` branch calls `setDestRegion(regionCode)`, but `regionTarget` can only ever be set to `'send'` — the Destination `InputCard` receives no `onRegionClick` prop and no `region` prop, so there is no UI path that sets `regionTarget` to `'receive'`.
This also means crypto destinations never get a user-selectable region. The `SET_RECEIVE` reducer already defaults non-BTC crypto destinations to `'USD'`, but the user cannot change it. If destination-region picking is intended for a future milestone, a comment clarifying that would avoid confusion; if it is not, the branch and the `setDestRegion` call below can be removed.
How can I resolve this? If you propose a fix, please make it concise.| } | ||
| }); | ||
| }); | ||
|
|
||
| // Wait one frame for the portal to mount before attaching the observer. | ||
| requestAnimationFrame(() => { | ||
| const listbox = getDialog()?.querySelector('[role="listbox"]'); | ||
| if (listbox) { | ||
| observer.observe(listbox, { childList: true, subtree: true }); | ||
| } | ||
| const opts = getOptions(); | ||
| if (opts.length) setHighlight(opts[0]); | ||
| }); | ||
|
|
||
| document.addEventListener('keydown', onKeyDown, true); | ||
| document.addEventListener('pointerover', onPointerOver, true); |
There was a problem hiding this comment.
RAF callback can re-attach observer after cleanup
The requestAnimationFrame callback captures observer by closure, but its cleanup call (observer.disconnect()) runs immediately when open becomes false. If the component unmounts or open flips before the first animation frame fires, the cleanup runs first, then the RAF callback executes and calls observer.observe(listbox, ...) — re-attaching the observer after it was already disconnected.
This is an unlikely race in practice (the dialog animation would need to complete in under one frame), but a cancelled-frame guard makes the intent clearer:
let cancelled = false;
requestAnimationFrame(() => {
if (cancelled) return;
const listbox = getDialog()?.querySelector('[role="listbox"]');
if (listbox) {
observer.observe(listbox, { childList: true, subtree: true });
}
const opts = getOptions();
if (opts.length) setHighlight(opts[0]);
});
return () => {
cancelled = true;
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('pointerover', onPointerOver, true);
document.removeEventListener('mousemove', onMouseMove, true);
observer.disconnect();
};Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-visualizer/src/hooks/useCommandNav.ts
Line: 90-105
Comment:
**RAF callback can re-attach observer after cleanup**
The `requestAnimationFrame` callback captures `observer` by closure, but its cleanup call (`observer.disconnect()`) runs immediately when `open` becomes `false`. If the component unmounts or `open` flips before the first animation frame fires, the cleanup runs first, then the RAF callback executes and calls `observer.observe(listbox, ...)` — re-attaching the observer after it was already disconnected.
This is an unlikely race in practice (the dialog animation would need to complete in under one frame), but a cancelled-frame guard makes the intent clearer:
```ts
let cancelled = false;
requestAnimationFrame(() => {
if (cancelled) return;
const listbox = getDialog()?.querySelector('[role="listbox"]');
if (listbox) {
observer.observe(listbox, { childList: true, subtree: true });
}
const opts = getOptions();
if (opts.length) setHighlight(opts[0]);
});
return () => {
cancelled = true;
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('pointerover', onPointerOver, true);
document.removeEventListener('mousemove', onMouseMove, true);
observer.disconnect();
};
```
How can I resolve this? If you propose a fix, please make it concise.| {selection.type === 'fiat' && fiatRailOptions ? ( | ||
| <RailDropdown | ||
| key={selection.code} | ||
| selection={selection} | ||
| options={fiatRailOptions} | ||
| onSelect={onRailChange} | ||
| currentRail={rail} | ||
| /> | ||
| <div className={styles.tabDivider} /> | ||
| <AccountTab | ||
| label="Internal" | ||
| isActive={selection.isInternal} | ||
| onClick={onToggleInternal} | ||
| tooltipText={TOOLTIP_TEXT.internal} | ||
| ) : selection.type === 'fiat' && fiatRail ? ( | ||
| <div className={clsx(styles.propertyRow, styles.propertyRowStatic)}> | ||
| <span className={styles.propertyLabel}>Rail</span> | ||
| <span className={styles.propertyValue}> | ||
| <span>{fiatRail}</span> | ||
| </span> | ||
| </div> | ||
| ) : null} |
There was a problem hiding this comment.
Rail row shown for internal fiat sources
The Rail row (both the dropdown and the static variant) is rendered whenever selection.type === 'fiat', with no guard for selection.isInternal. When FundingModelSection sets the mode to 'internal', SET_SOURCE_FUNDING_MODE marks send.isInternal = true, but InputCard still shows the Rail UI — even though internal Grid accounts don't use external payment rails.
Both conditions on lines 386 and 394 need an additional && !selection.isInternal check to suppress the Rail row when the account is internal.
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/grid-visualizer/src/components/InputCard/InputCard.tsx
Line: 386-401
Comment:
**Rail row shown for internal fiat sources**
The Rail row (both the dropdown and the static variant) is rendered whenever `selection.type === 'fiat'`, with no guard for `selection.isInternal`. When `FundingModelSection` sets the mode to `'internal'`, `SET_SOURCE_FUNDING_MODE` marks `send.isInternal = true`, but `InputCard` still shows the Rail UI — even though internal Grid accounts don't use external payment rails.
Both conditions on lines 386 and 394 need an additional `&& !selection.isInternal` check to suppress the Rail row when the account is internal.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Major design and UX overhaul of the Grid Visualizer (flow builder), plus compatibility with recent schema updates.
useCommandNavhook for keyboard navigationChanges
components/grid-visualizer/FundingTogglecomponent with newFundingModelSectionpage.tsxflow-path.tsandcode-generator.tsfor improved flow logicTest plan
?embed=true&theme=dark) renders correctlyMade with Cursor