Thank you for helping improve the Conduit web interface. This guide covers setup, design rules, component conventions, and the PR process.
- Code of Conduct
- Getting Started
- Repository Layout
- Development Workflow
- Design System
- Component Conventions
- Contract Integration
- Testing
- Commit Convention
- Pull Request Process
This project follows the Contributor Covenant Code of Conduct. By participating you agree to uphold it. Report unacceptable behaviour to conduct@conduit.sh.
| Tool | Version |
|---|---|
| Node.js | ≥ 20 |
| npm | ≥ 10 |
| A Stellar wallet | Freighter, xBull, Albedo, or Hana |
git clone https://github.com/conduit-protocol/conduit-app
cd conduit-app
npm install
# Copy environment variables
cp .env.example .env.localEdit .env.local:
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
NEXT_PUBLIC_FACTORY_CONTRACT_ID=C... # from conduit-contracts deploy
NEXT_PUBLIC_GOVERNOR_CONTRACT_ID=C... # from conduit-contracts deploy
NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.orgDon't have deployed contract IDs? Use the testnet deployments listed in the conduit-contracts releases.
Start the development server:
npm run dev
# → http://localhost:3000npm run typecheck # tsc --noEmit
npm run lint # ESLint
npm test # Vitest unit tests
npm run build # production build (catches Next.js-specific errors)conduit-app/
├── app/ # Next.js App Router pages
│ ├── layout.tsx # Root layout — Navbar, Providers, footer
│ ├── page.tsx # Landing page
│ ├── (marketing)/
│ │ └── about/page.tsx # Protocol explainer
│ ├── streams/page.tsx # Stream list (receiving + sending tabs)
│ ├── stream/[id]/page.tsx # Single stream view + actions
│ ├── create/page.tsx # Create stream form
│ └── dashboard/page.tsx # Aggregate sender stats
├── components/
│ ├── ui/ # Primitive design system components
│ │ ├── README.md # ← read this before adding UI components
│ │ ├── Button.tsx
│ │ ├── Card.tsx
│ │ ├── Input.tsx
│ │ ├── Badge.tsx
│ │ ├── ProgressBar.tsx
│ │ └── Modal.tsx
│ ├── stream/ # Stream-specific composed components
│ │ ├── StreamCard.tsx # Summary card used in list
│ │ ├── StreamActions.tsx # Role-gated action buttons
│ │ ├── StreamTimeline.tsx # Horizontal progress timeline
│ │ ├── RateTicker.tsx # Live per-second counter
│ │ └── WithdrawButton.tsx # Withdraw with pending state
│ ├── ConnectButton.tsx # Freighter connect trigger (via WalletContext)
│ ├── Navbar.tsx
│ └── Providers.tsx # Context providers tree
├── contexts/
│ └── WalletContext.tsx # Wallet state, address, signTransaction
├── lib/
│ ├── soroban.ts # Soroban RPC client + helpers
│ ├── factory.ts # DripFactory call wrappers
│ ├── stream.ts # DripStream call wrappers
│ ├── tokens.ts # Known Stellar asset list
│ └── format.ts # Amount formatting, relative time
├── tailwind.config.ts
├── next.config.ts
├── tsconfig.json
└── .env.example
main ← always deployable
└── feat/your-feature
- Fork and clone your fork.
- Create a branch:
git checkout -b feat/my-feature
- Make changes with frequent
npm run typecheckchecks. - Test in the browser — connect a Freighter wallet on testnet and walk through the flow you changed.
- Run the full check suite:
npm run typecheck && npm run lint && npm test && npm run build
- Push and open a PR.
The Conduit app is black and white only. This is a hard constraint, not a preference.
text-black text-white text-gray-{50..950}
bg-black bg-white bg-gray-{50..950}
border-black border-white border-gray-{50..950}
ring-black ring-white ring-gray-{50..950}
text-blue-* text-red-* text-green-* text-indigo-*
bg-blue-* bg-red-* bg-emerald-* bg-violet-*
Any Tailwind hue-named colour class
text-green-600 for positive balance deltas and text-red-600 for negative deltas are permitted only when accompanied by an aria-label so colour is never the sole signal:
<span className="text-green-600 font-mono" aria-label="increase">
+{fromStroops(delta)} XLM
</span>- Numbers and addresses: always
font-mono tabular-nums - Section headings:
font-black tracking-tight - Labels and metadata:
text-xs text-gray-400 - Borders: prefer
border-gray-100for containers,border-blackfor interactive focus states
- Page containers:
max-w-3xl mx-auto px-4 - Vertical rhythm between sections:
mb-10ormb-16 - Card padding:
p-4orp-6
See components/ui/README.md for the full component API.
Next.js App Router defaults to Server Components. Use 'use client' only when the component needs:
- Browser APIs (
window,navigator,requestAnimationFrame) - React state (
useState,useEffect,useReducer) - Event handlers
- Context consumers
Keep data fetching in Server Components and push interactivity as far down the tree as possible.
| Pattern | Example |
|---|---|
| Page components | app/streams/page.tsx → default export |
| UI primitives | components/ui/Button.tsx → named export Button |
| Feature components | components/stream/StreamCard.tsx → named export StreamCard |
| Hooks | hooks/useStream.ts → named export useStream |
| Lib helpers | lib/format.ts → named exports |
Define prop types as named interfaces directly above the component:
interface StreamCardProps {
id: string;
counterparty: string;
role: 'sender' | 'recipient';
// ...
}
export function StreamCard({ id, counterparty, role }: StreamCardProps) {
// ...
}Do not use inline React.FC<{...}> — it hides the component name in stack traces.
Every data-fetching component must handle a loading state. Use a skeleton that mirrors the shape of the loaded content:
if (loading) {
return (
<div className="card animate-pulse">
<div className="h-4 bg-gray-100 rounded w-1/2 mb-2" />
<div className="h-3 bg-gray-100 rounded w-1/3" />
</div>
);
}Display errors inline, never alert(). Use a simple bordered container:
if (error) {
return (
<div className="border border-gray-200 p-4 text-sm text-gray-500">
{error.message}
</div>
);
}All Soroban calls go through lib/soroban.ts and the wrappers in lib/stream.ts / lib/factory.ts. Do not import @stellar/stellar-sdk directly in page or component files.
Read-only calls (simulation only):
// lib/stream.ts
export async function getWithdrawable(source: string, streamAddress: string): Promise<bigint>
export async function getStreamInfo(source: string, streamAddress: string): Promise<StreamInfo>Mutating calls (require signTx from WalletContext):
// lib/stream.ts — signTx is (xdrBase64: string) => Promise<string>
export async function withdraw(sender, streamAddress, amount, signTx): Promise<string>
export async function cancel(sender, streamAddress, signTx): Promise<string>Transactions are assembled client-side via Soroban simulation, signed by the user's wallet, and submitted to the RPC. Never hard-code a secret key.
We use Vitest for unit tests. Integration / E2E tests are not yet in scope.
- Pure utility functions in
lib/format.tsandlib/tokens.ts - Any non-trivial conditional logic in components (test the logic, not the rendering)
- Styling — visual review in the browser
- Soroban RPC calls — mock at the
lib/soroban.tsboundary
npm test # run once
npm run test:watch # watch mode
npx vitest run --coverage # with coverage reportWe follow Conventional Commits:
<type>(<scope>): <short description>
Types: feat, fix, refactor, style, test, docs, chore, perf
Scopes: dashboard, streams, stream, create, ui, wallet, lib, layout, ci, deps
Examples:
feat(dashboard): add aggregate flow rate stat card
fix(stream): handle cancelled state in RateTicker — return 0 immediately
refactor(ui): extract SkeletonCard into shared component
style(create): align form labels with 12px grid
test(lib): add edge cases for fromStroops with zero and max i128
chore(deps): bump next to 15.2.1
feat/<issue-number>-short-slug # new feature or UI
fix/<issue-number>-short-slug # bug fix
refactor/<issue-number>-short-slug # component refactor
test/<issue-number>-short-slug # tests only
docs/<issue-number>-short-slug # docs only
style/<issue-number>-short-slug # visual / design only
Examples: fix/1-start-time-buffer, feat/5-force-cancel-ui
Every PR must contain at least 5 commits. They must follow this logical order — reviewers read them in sequence:
| # | Commit type | What it contains |
|---|---|---|
| 1 | test(<scope>): add unit test for <issue> |
Tests for the logic being changed — written first. UI logic tests live in lib/ or alongside components. Expected to fail (or not exist) before the fix. |
| 2 | fix(<scope>) or feat(<scope>): core implementation |
The minimal change to make tests pass: the logic fix, new hook, or new contract call. No styling in this commit. |
| 3 | feat(<scope>): UI components and layout |
Rendering layer: the JSX, props, conditional display. No business logic in this commit. |
| 4 | style(<scope>): visual polish and accessibility |
Tailwind classes, aria attributes, loading skeletons, responsive adjustments. No logic. |
| 5 | chore(<scope>): typecheck + lint pass |
Fix any TypeScript or ESLint issues surfaced by the change. No functional changes. |
Rules:
- Every commit body must explain why this change is needed, not just what it does.
- Reference the issue in the core implementation commit:
Closes #1. - No merge commits or
fixup!commits in the branch — rebase and amend before review. - Commits 2 and 3 must be separately
git cherry-pick-able (no styling mixed into logic commits).
test(create): add test — streams with <300s buffer fail BackdatedStream
fix(create): increase startTime buffer to 300s to cover tx inclusion lag
feat(create): show user-friendly error message for BackdatedStream failures
style(create): add buffer-time note to duration helper text
chore(create): typecheck and lint clean after create page changes
- Branch name follows the naming convention above
- PR title:
fix(create): increase start time buffer to 300s (#1) - PR body includes
Closes #<n>orFixes #<n> - At least 5 commits, each with a body explaining why
-
npm run typecheck— no errors -
npm run lint— no warnings -
npm test— all tests pass -
npm run build— production build succeeds - Tested in the browser with a connected Freighter wallet on testnet (describe what you clicked)
- No hue-named colour classes introduced (exception:
text-red-600/text-green-600witharia-label) - Loading and error states handled for any new data-fetching UI
-
CHANGELOG.mdentry under[Unreleased] - Before/after screenshots included for any visible UI change
- Mandatory owner review: Every PR requires approval from @jaydbrown before it can be merged. This applies to all PRs — including docs, style, and chore PRs.
- PRs changing
lib/soroban.tsorcontexts/WalletContext.tsxadditionally require 1 further maintainer approval (2 approvals total) — these are critical security boundaries. - CI must be green (typecheck, lint, tests, build).
- Commit 1 is a test that fails on
main(or demonstrates the missing coverage) - Logic commit (commit 2) contains no Tailwind class changes
- UI commit (commit 3) contains no business logic
- No hue-named colour classes without an
aria-label - All data-fetching UI has a loading and error state
- No
Number()on large bigint values - No
anytypes in TypeScript - No direct imports of
@stellar/stellar-sdkin page or component files (onlylib/) - Wallet address validation uses
StrKey.isValidEd25519PublicKey()(not length-only) — this is the standard going forward; existing regex-based validation inTokenSelector.tsx,BulkWithdrawButton.tsx, andBatchStreamCreator.tsxpredates this checklist item and is tracked separately for migration, not exempted from it. New/changed validation code must meet this bar. - 5-commit minimum is met and commits are in logical order
- Screenshots attached for visible UI changes
For any visible UI change, include before/after screenshots in the PR description.
By contributing you agree that your contributions will be licensed under the MIT License.