Skip to content

Grid Visualizer: design overhaul and UX improvements#271

Merged
patcapulong merged 18 commits intomainfrom
pat/visualizer-design-updates
Mar 16, 2026
Merged

Grid Visualizer: design overhaul and UX improvements#271
patcapulong merged 18 commits intomainfrom
pat/visualizer-design-updates

Conversation

@patcapulong
Copy link
Copy Markdown
Contributor

Summary

Major design and UX overhaul of the Grid Visualizer (flow builder), plus compatibility with recent schema updates.

Changes

  • 26 files changed across components/grid-visualizer/
  • Replaced FundingToggle component with new FundingModelSection
  • Simplified page state machine in page.tsx
  • Updated flow-path.ts and code-generator.ts for improved flow logic

Test plan

  • Verify currency pickers show all 24 fiat currencies with correct flags
  • Test popular flows load and generate correct API steps
  • Test a cross-currency flow (e.g., USD → BRL) end-to-end
  • Test JIT funding flow with crypto source
  • Verify responsive behavior on smaller viewports
  • Check embed mode (?embed=true&theme=dark) renders correctly

Made with Cursor

…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
…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
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 16, 2026

Greptile Summary

This PR delivers a significant design and UX overhaul of the Grid Visualizer: the old FundingToggle + tab-based InputCard is replaced by a new FundingModelSection with three explicit funding mode options, and property rows (Rail, Network, Region) replace the old inline badges. The state machine in useFlowBuilder is refactored around a sourceFundingMode enum and an explicit sourceRail, which are correctly threaded through to both buildFlowPath and generateSteps. A new useCommandNav hook provides unified keyboard/mouse navigation for the Command modal, and currencies are now alphabetically sorted with a curated popular list.

Key issues found:

  • Rail row visible for internal fiat sources (InputCard.tsx lines 386–401): The Rail dropdown/static row has no !selection.isInternal guard. When the user picks "Internal" in FundingModelSection, send.isInternal becomes true but the Rail UI still renders, showing a rail that has no meaning for internal Grid accounts.
  • Dead regionTarget === 'receive' branch (page.tsx lines 273–280): The Destination InputCard receives no onRegionClick or region prop, so regionTarget can never be set to 'receive'. The else if branch and the setDestRegion call inside it are unreachable.
  • formatCurl is unguarded against instructional steps (code-generator.ts): The new isInstructional step type sets method and endpoint to empty strings. The UI correctly avoids calling formatCurl on such steps, but the function itself has no early-return guard, making it fragile for direct callers or future code paths.
  • requestAnimationFrame / cleanup race in useCommandNav (useCommandNav.ts): The observer setup inside the RAF callback can re-attach after the cleanup function has already called observer.disconnect(), if open flips before the first frame fires.

Confidence Score: 3/5

  • Safe to merge after addressing the internal-mode Rail row regression and the dead regionTarget branch; remaining issues are minor.
  • The core refactor is well-structured and most previously-flagged issues are resolved (sourceRail forwarded to flow diagram, JIT gating on jitEligible, SWAP resetting sourceFundingMode). However, the Rail row being visible for internal fiat sources is a new regression that produces misleading UI, and the dead regionTarget === 'receive' branch suggests crypto destination region picking is incomplete. These warrant a fix before merging.
  • Pay close attention to InputCard.tsx (internal mode Rail row regression) and page.tsx (dead regionTarget branch / missing destination region UX).

Important Files Changed

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]
Loading

Comments Outside Diff (1)

  1. components/grid-visualizer/src/lib/code-generator.ts, line 331-368 (link)

    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:

    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

Comment thread components/grid-visualizer/src/hooks/useFlowBuilder.ts
Comment thread components/grid-visualizer/src/components/InputCard/InputCard.tsx
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
Comment thread components/grid-visualizer/src/hooks/useFlowBuilder.ts
@patcapulong
Copy link
Copy Markdown
Contributor Author

@greptile i made the fixes and i resolved one of your comments could you review again

Comment thread components/grid-visualizer/src/components/InputCard/InputCard.tsx
Comment thread components/grid-visualizer/src/lib/flow-path.ts Outdated
Comment thread components/grid-visualizer/src/lib/flow-path.ts
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
Comment thread components/grid-visualizer/src/components/InputCard/InputCard.tsx Outdated
Comment thread components/grid-visualizer/src/app/page.tsx
@patcapulong patcapulong merged commit faf3aaf into main Mar 16, 2026
6 checks passed
@patcapulong patcapulong deleted the pat/visualizer-design-updates branch March 16, 2026 22:16
Comment on lines 273 to +280
}
/>

{/* 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)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +90 to +105
}
});
});

// 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +386 to +401
{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}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants