--bg: #0d1117 /* Main background */
--surface: #161b22 /* Card/modal background */
--border: #30363d /* Borders and dividers */
--text: #e6edf3 /* Primary text */
--muted: #8b949e /* Secondary text */
--accent: #58a6ff /* Primary actions (blue) */
--success: #3fb950 /* Success states (green) */
--warning: #d29922 /* Warning states (orange) */
--error: #f85149 /* Error states (red) */Font Family: system-ui, -apple-system, sans-serif
Line Height: 1.5
Sizes:
- H2 (Modal Title): 1.25rem (20px)
- H3 (Wallet Name): 1rem (16px)
- Body: 0.9rem (14.4px)
- Small: 0.85rem (13.6px)
- Tiny: 0.75rem (12px)0.25rem = 4px
0.5rem = 8px
0.75rem = 12px
1rem = 16px
1.5rem = 24px
2rem = 32px
2.5rem = 40px
3rem = 48pxSmall: 4px
Medium: 6px
Large: 8px
XLarge: 12px
Round: 50%- Background: var(--surface)
- Border: 1px solid var(--border)
- Border Radius: 8px
- Padding: 1.5rem (24px) desktop, 1rem (16px) mobile
- Margin Bottom: 1rem
- Transition: border-color 0.2s, transform 0.2s
- Animation: fadeInUp 0.4s ease both
- Border Color: var(--accent)
- Transform: translateY(-1px)
- Margin: 0 0 0.75rem
- Font Size: 1.125rem (18px)
- Font Weight: 600
- Color: var(--text)
- Display: Flex, align-items center, gap 0.5rem
- Font Size: 1.1rem
- Margin: 0
- Color: var(--muted)
- Font Size: 0.9rem (14.4px)
- Padding: 2rem (32px) desktop, 1.5rem (24px) mobile
- All other properties same as base .card
- Width: 90% (mobile), max 480px (desktop)
- Max Height: 90vh
- Border Radius: 12px
- Border: 1px solid var(--border)
- Background: rgba(0, 0, 0, 0.7)
- Backdrop Filter: blur(4px)
- Z-index: 1000
- Padding: 1.5rem (24px)
- Border Bottom: 1px solid var(--border)
- Display: Flex, space-between
- Font Size: 1.25rem
- Font Weight: 600
- Color: var(--text)
- Margin: 0
- Size: 32x32px
- Font Size: 1.5rem
- Color: var(--muted)
- Hover: Background var(--border), Color var(--text)
- Border Radius: 4px
- Padding: 1.5rem
- Font Size: 0.9rem
- Color: var(--muted)
- Line Height: 1.6
- Padding: 0 1.5rem 1.5rem
- Gap: 0.75rem (12px)
- Padding: 1rem (16px)
- Background: var(--bg)
- Border: 1px solid var(--border)
- Border Radius: 8px
- Display: Flex, align-items center
- Gap: 1rem
Hover State:
- Border Color: var(--accent)
- Transform: translateY(-2px)
- Box Shadow: 0 4px 12px rgba(88, 166, 255, 0.1)
Loading State:
- Border Color: var(--accent)
- Opacity: 0.6
- Size: 48x48px
- Font Size: 2rem
- Background: var(--surface)
- Border Radius: 8px
- Display: Flex, center
- Flex: 1
Name:
- Font Size: 1rem
- Color: var(--text)
- Margin Bottom: 0.25rem
Description:
- Font Size: 0.85rem
- Color: var(--muted)
- Size: 20x20px
- Border: 2px solid var(--border)
- Border Top: 2px solid var(--accent)
- Border Radius: 50%
- Animation: Spin 0.8s linear infinite
- Margin: 0 1.5rem 1.5rem
- Padding: 1rem
- Background: rgba(248, 81, 73, 0.1)
- Border: 1px solid rgba(248, 81, 73, 0.3)
- Border Radius: 8px
- Display: Flex, gap 0.75rem
Icon:
- Font Size: 1.25rem
- Color: var(--error)
Title:
- Font Weight: 600
- Color: var(--error)
- Margin Bottom: 0.25rem
Message:
- Font Size: 0.85rem
- Color: var(--muted)
- Padding: 3rem 1.5rem
- Text Align: Center
Icon:
- Size: 64x64px
- Background: var(--success)
- Border Radius: 50%
- Font Size: 2rem
- Color: white
- Margin: 0 auto 1rem
- Animation: ScaleIn 0.4s ease
Title:
- Font Size: 1.25rem
- Color: var(--text)
- Margin Bottom: 0.5rem
Message:
- Font Size: 0.9rem
- Color: var(--muted)
- Padding: 1rem 1.5rem 1.5rem
- Display: Flex, gap 0.75rem
- Font Size: 0.85rem
- Color: var(--muted)
Icon:
- Font Size: 1.25rem
- Width: 90% (mobile), max 500px (desktop)
- Padding: 2.5rem (40px)
- Border Radius: 16px
- Border: 1px solid var(--border)
- Background: rgba(0, 0, 0, 0.85)
- Backdrop Filter: blur(8px)
- Z-index: 1001
- Position: Absolute, top 1rem, right 1rem
- Padding: 0.5rem 1rem
- Font Size: 0.9rem
- Color: var(--muted)
- Hover: Color var(--text), Background var(--border)
- Border Radius: 4px
- Text Align: Center
- Margin Bottom: 2.5rem
Icon:
- Font Size: 4rem
- Margin Bottom: 1.5rem
- Animation: BounceIn 0.6s ease
Title:
- Font Size: 1.5rem
- Color: var(--text)
- Margin Bottom: 1rem
Description:
- Font Size: 1rem
- Color: var(--muted)
- Line Height: 1.6
- Max Width: 400px
- Margin: 0 auto
- Display: Flex, gap 0.5rem
- Justify Content: Center
- Margin Bottom: 2rem
Indicator:
- Width: 32px
- Height: 4px
- Background: var(--border)
- Border Radius: 2px
- Transition: All 0.3s
Active:
- Background: var(--accent)
- Width: 48px
Completed:
- Background: var(--success)
- Width: 100%, max 200px
- Padding: 0.75rem 2rem
- Background: var(--accent)
- Color: white
- Border Radius: 8px
- Font Size: 1rem
- Font Weight: 500
- Hover: Background #4a8fd8, Transform translateY(-2px)
- Box Shadow: 0 4px 12px rgba(88, 166, 255, 0.3)
- Padding: 0.5rem 1rem
- Background: var(--accent)
- Color: white
- Border Radius: 6px
- Font Size: 0.9rem
- Font Weight: 500
- Hover: Background #4a8fd8, Transform translateY(-1px)
- Box Shadow: 0 2px 8px rgba(88, 166, 255, 0.3)
- Padding: 0.5rem 1rem
- Background: var(--surface)
- Border: 1px solid var(--border)
- Color: var(--text)
- Border Radius: 6px
- Font Size: 0.9rem
- Display: Flex, gap 0.5rem
- Hover: Border Color var(--accent)
Status Dot:
- Size: 8x8px
- Background: var(--success)
- Border Radius: 50%
- Animation: Pulse 2s infinite
- Position: Absolute, top calc(100% + 0.5rem), right 0
- Min Width: 200px
- Padding: 0.75rem
- Background: var(--surface)
- Border: 1px solid var(--border)
- Border Radius: 8px
- Box Shadow: 0 4px 12px rgba(0, 0, 0, 0.3)
- Z-index: 100
Dropdown Item:
- Padding: 0.5rem
- Font Size: 0.85rem
- Display: Flex, space-between
Label:
- Color: var(--muted)
Value:
- Color: var(--text)
- Font Weight: 500
- Text Transform: Capitalize
Disconnect Button:
- Width: 100%
- Margin Top: 0.5rem
- Padding: 0.5rem
- Background: none
- Border: 1px solid var(--border)
- Color: var(--text)
- Border Radius: 6px
- Font Size: 0.85rem
- Hover: Background rgba(248, 81, 73, 0.1), Border var(--error), Color var(--error)
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
Duration: 0.2s
Timing: ease@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Duration: 0.3s
Timing: ease@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Duration: 0.2s
Timing: ease@keyframes scaleIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
Duration: 0.4s
Timing: ease@keyframes bounceIn {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
Duration: 0.6s
Timing: ease
---
## Contrast Audit Adjustments (Dark Theme)
### Changes Made for WCAG AA Compliance
- Added `--error: #f85149` to color palette for consistent error styling.
- Updated badge text colors from status-specific colors (e.g., `var(--success)`) to `var(--text)` for improved contrast on colored backgrounds.
- Updated network badge text colors to `var(--text)` for consistency and contrast.
- Updated status badge text colors in `STATUS_COLOR` to `COLOR.text` for all statuses.
- Updated error state text colors in WalletConnectionModal from red/muted to `var(--text)` for higher contrast.
- Updated disconnect button hover colors to use `var(--error)` variable.
### Rationale
- Badge and status text on semi-transparent colored backgrounds had contrast ratios below 4.5:1 for normal text.
- Error text on light red background had insufficient contrast.
- Changes maintain aesthetic by keeping colored backgrounds for visual cues while ensuring text readability.
- All adjustments use token variables to allow future fine-tuning without component overrides.@keyframes spin {
to { transform: rotate(360deg); }
}
Duration: 0.8s
Timing: linear
Iteration: infinite@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
Duration: 2s
Timing: ease
Iteration: infinite- Default: Base styles
- Hover: Color change, slight lift (translateY -1px to -2px)
- Active: Slight press (translateY 0)
- Disabled: Opacity 0.6, cursor not-allowed
- Loading: Spinner visible, opacity 0.6
- Default: Border var(--border)
- Hover: Border var(--accent), lift, shadow
- Active: Border var(--accent)
- Disabled: Opacity 0.6
- Default: Border var(--border)
- Focus: Border var(--accent), outline none
- Error: Border var(--error)
- Success: Border var(--success)
Mobile: < 768px
- Modal width: 90%
- Padding: 1rem
- Font sizes: -10%
Tablet: 768px - 1024px
- Modal width: 80%
- Padding: 1.5rem
Desktop: > 1024px
- Modal max-width: 480px
- Padding: 2rem- Outline: 2px solid var(--accent)
- Outline Offset: 2px
- Tab: Navigate through interactive elements
- Enter/Space: Activate buttons
- Escape: Close modals
- Modal: role="dialog", aria-modal="true"
- Close button: aria-label="Close"
- Loading: aria-busy="true"
- Error: role="alert"
- Text on background: 7:1 (AAA)
- Muted text: 4.5:1 (AA)
- Interactive elements: 3:1 (AA)
"Connect Wallet"
"Choose a wallet to connect to Creditra. Your wallet will be used to access credit lines on Stellar."
"🔒 We never store your private keys. Your wallet remains secure."
"Wallet Connected!" "You're all set to start using Creditra"
- Not Found: "[Wallet] wallet not found. Please install the extension."
- Failed: "Failed to connect wallet. Please try again."
- Wrong Network: "Please switch to Stellar network in your wallet."
- "Welcome to Creditra" / "Your adaptive credit protocol on Stellar blockchain"
- "Credit Evaluation" / "We analyze your on-chain activity to determine your credit limit and terms"
- "Flexible Credit Lines" / "Draw and repay credit as needed with dynamic interest rates based on your risk profile"
- Connect: "Connect Wallet"
- Next: "Next"
- Complete: "Get Started"
- Skip: "Skip"
- Disconnect: "Disconnect"
- Width: 100%, max 480px
- Border Radius: 12px
- Background: var(--surface)
- Border: 1px solid var(--border)
- Box Shadow: 0 16px 40px rgba(0,0,0,0.4)
- Padding: 1.5rem
- Border Bottom: 1px solid var(--border)
- Title Size: 1.25rem
- Subtitle: 0.85rem (Credit Line Name / ID)
- Layout: Grid or Flex Row
- "Total Due" Emphasis: Font Size 2rem, Font Weight 700, Color var(--error)
- "Accrued Interest": Font Size 0.85rem, Color var(--muted)
- Label: Font Size 0.9rem, Color var(--text)
- Input Element: Height 48px, Font Size 1.25rem, Padding 0 1rem
- Focus State: Border var(--accent), Box Shadow 0 0 0 2px rgba(88, 166, 255, 0.2)
- Error State: Border var(--error), Text Color var(--error)
- Layout: Grid (4 columns), gap 0.5rem
- Button Style: Base Ghost/Outline style
- Active State: Background rgba(88, 166, 255, 0.1), Border var(--accent), Color var(--accent)
- Layout: Flex column with dividers
- Remaining Debt: Highlighted in green if 0, else white
- Progress Bar (Utilization): Animate width change from old percentage to new percentage (CSS
transition: width 0.4s ease).
- "Review Repayment" Button: Primary style (var(--accent)).
- Pending State Container: Flex centered, large spinner, muted helper text.
- Success State Container: Confetti or Bounce-In checkmark icon (color: var(--success)).
- Header: "Make a Repayment"
- Input Label: "Amount to Repay"
- Warning: "Amount exceeds outstanding debt" or "Insufficient wallet balance"
- Success: "Repayment Successful! Your credit line balance has been updated."
- Header container padding:
0.75rem 1.5rem - Header gap between major regions:
1rem - Vertical alignment: all items centered
- Background:
var(--surface) - Bottom border:
1px solid var(--border)
- Font size:
1.25rem - Font weight:
700 - Min interactive height:
44px - Vertical padding:
0.5rem - Focus:
2pxaccent outline with2pxoffset
- Group gap:
0.5rem - Link font size:
0.9rem - Link font weight:
500 - Link padding:
0.625rem 0.75rem - Min interactive height:
44px - Border radius:
6px - Default color:
var(--muted) - Hover:
var(--text)+rgba(88, 166, 255, 0.1)background - Active route state: accent text, soft accent background, and subtle accent border
- Focus-visible:
2px solid var(--accent)outline, offset2px
- Connect/disconnected trigger buttons share the same min height (
44px) and min width (168px) to prevent layout shift - Shared typography:
0.9rem, weight500 - Shared border radius:
6px - Shared interaction states:
- Hover: color/background change only
- Active:
translateY(1px) - Focus-visible:
2pxaccent outline with2pxoffset
All form fields across the application follow a unified structure to improve accessibility, reduce cognitive load, and ensure consistent error recovery. This pattern is implemented through the FormField component.
Every form field must include:
- Label: Programmatically associated with the input via
htmlFor/id - Required Indicator: Visual asterisk (*) with
aria-label="required"for screen readers - Help Text: Instructional text linked via
aria-describedby - Error Messaging: High-contrast error messages with
aria-invalidandaria-live="polite"
<FormField
id="email" // Required: unique identifier
label="Email Address" // Required: visible label text
type="email" // Input type (text, email, password, etc.)
required={true} // Shows asterisk and sets aria-required
helpText="We'll never share your email" // Optional: guidance text
error={errors.email} // Optional: error message string
inputProps={{ // Pass-through props for the input
value,
onChange,
placeholder: "you@example.com",
autoComplete: "email"
}}
/>- Font size:
0.9rem(14.4px) - Font weight:
500 - Color:
var(--text) - Line height:
var(--lh-small)(1.5) - Margin bottom:
var(--space-sm)(0.5rem)
- Color:
var(--error) - Margin left:
var(--space-xs)(0.25rem) - Font weight:
600 - Announced as "required" to screen readers
- Font size:
0.85rem(13.6px) - Color:
var(--muted) - Line height:
var(--lh-small)(1.5) - Margin:
0
- Width:
100% - Padding:
0.625rem 0.75rem - Font size:
0.9rem - Line height:
var(--lh-body)(1.6) - Color:
var(--text) - Background:
var(--surface) - Border:
1px solid var(--border) - Border radius:
6px - Transition:
border-color 0.2s ease, box-shadow 0.2s ease
Hover State:
- Border color:
var(--accent)
Focus State:
- Outline:
none - Border color:
var(--accent) - Box shadow:
0 0 0 3px rgba(88, 166, 255, 0.1)
Error State:
- Border color:
var(--error) - Box shadow on focus:
0 0 0 3px rgba(248, 81, 73, 0.1)
- Display:
flex - Align items:
flex-start - Gap:
var(--space-sm)(0.5rem) - Padding:
var(--space-md)(0.75rem) - Font size:
0.85rem - Color:
var(--error) - Background:
rgba(248, 81, 73, 0.1) - Border:
1px solid rgba(248, 81, 73, 0.3) - Border radius:
6px - Line height:
var(--lh-small)(1.5)
Error Icon:
- Width:
16px - Height:
16px - Flex shrink:
0 - Margin top:
1px aria-hidden="true"
aria-describedby: Links input to help text and error messagesaria-invalid: Set totruewhen error is presentaria-required: Set totruefor required fieldsaria-live="polite": On error messages for dynamic announcements
- Keyboard navigation follows logical tab order
- Focus rings are clearly visible (2px solid accent with 2px offset)
- Focus states use high-contrast colors
- Labels are programmatically associated with inputs
- Required indicator is announced as "required"
- Help text is announced when field receives focus
- Error messages are announced when they appear
- Error messages use
role="alert"implicitly viaaria-live
For checkbox and radio inputs, use the .form-field--checkbox or .form-field--radio modifier:
<div className="form-field form-field--checkbox">
<input
id="accept-terms"
type="checkbox"
checked={accepted}
onChange={handleChange}
className="form-field__input"
aria-describedby="accept-terms-help"
aria-required="true"
/>
<label htmlFor="accept-terms" className="form-field__label">
I accept the Terms and Conditions
<span className="form-field__required" aria-label="required">*</span>
</label>
<p id="accept-terms-help" className="form-field__help">
You must accept to continue
</p>
</div>Layout:
- Flex direction:
row - Align items:
flex-start - Gap:
var(--space-md)(0.75rem) - Margin bottom:
var(--space-lg)(1rem)
Input:
- Width:
auto - Margin top:
2px(aligns with label baseline) - Cursor:
pointer
Label:
- Cursor:
pointer - User select:
none
For specialized inputs (file uploads, custom controls), use the as="custom" prop:
<FormField
id="file-upload"
label="Upload Document"
helpText="PDF or CSV files only"
error={fileError}
as="custom"
>
{(props) => (
<input
{...props}
type="file"
accept=".pdf,.csv"
onChange={handleFileChange}
/>
)}
</FormField>The render prop receives:
id: The field IDaria-describedby: Space-separated IDs of help text and erroraria-invalid: Boolean indicating error statearia-required: Boolean indicating required state
The standardized pattern has been applied to:
-
Authentication Flows
LoginPage.tsx: Email/username and password fieldsRegisterPage.tsx: Email, password, and confirm password fieldsForgotPasswordPage.tsx: Email fieldResetPasswordPage.tsx: New password and confirm password fields
-
Functional Components
AmountInput.tsx: Amount to draw field with custom styling, pinned to design system v7 tokens (--space-*,--lh-*,--radius-*, semantic status tokens)RequestEvaluation.tsx: File upload and checkbox fields
- Validate on blur for better UX (don't show errors while typing)
- Clear errors when user starts correcting the field
- Use field-specific errors when possible (
error?.field === 'email') - Provide actionable error messages ("Please enter a valid email" not "Invalid input")
- For password fields, show strength indicators with
aria-live="polite"
- Label is programmatically associated with input
- Required fields show asterisk and announce "required"
- Help text is linked via
aria-describedby - Error messages appear with
aria-invalid="true" - Error messages use
aria-live="polite"for announcements - Focus rings are clearly visible
- Keyboard navigation (Tab/Shift+Tab) follows logical order
- Screen reader announces label, required status, help text, and errors
- Color contrast meets WCAG AA standards (4.5:1 for normal text)
- Form can be completed using keyboard only
For users with prefers-reduced-motion: reduce:
- All transitions are disabled
- Animations are removed
- Focus changes are instant
@media (prefers-reduced-motion: reduce) {
.form-field__input,
.form-field__textarea {
transition: none;
}
}import { useState } from 'react';
import { FormField } from '@/components/FormField';
import { PendingButton } from '@/components/PendingButton';
function LoginForm() {
const [formData, setFormData] = useState({ email: '', password: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Validation and submission logic
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<FormField
id="email"
label="Email Address"
type="email"
required
helpText="We'll never share your email"
error={errors.email}
inputProps={{
value: formData.email,
onChange: (e) => setFormData({ ...formData, email: e.target.value }),
placeholder: "you@example.com",
autoComplete: "email"
}}
/>
<FormField
id="password"
label="Password"
type="password"
required
error={errors.password}
inputProps={{
value: formData.password,
onChange: (e) => setFormData({ ...formData, password: e.target.value }),
placeholder: "Enter your password",
autoComplete: "current-password"
}}
/>
<PendingButton
type="submit"
pending={loading}
pendingLabel="Signing in..."
className="w-full"
>
Sign In
</PendingButton>
</form>
);
}RepayPreviewModal is a modal dialog designed for the GrantFox FWC26 campaign (#483) that allows borrowers to preview all financial, credit limit, interest savings, and wallet balance consequences of a proposed repayment before submitting.
export interface RepaymentCreditLine {
id: string;
name: string;
limit: number;
utilized: number;
apr: number;
nextPaymentAmount?: number;
}
export interface RepayPreviewModalProps {
isOpen: boolean;
creditLine: RepaymentCreditLine;
walletBalance: number;
repayAmount: number;
onClose: () => void;
onConfirm: (amount: number) => void | Promise<void>;
triggerRef?: React.RefObject<HTMLElement | null>;
title?: string;
confirmLabel?: string;
}- Header: Modal title, credit line badge, 44px close button.
- Hero Section: Proposed repayment amount display with Full Payoff / Partial Repayment status chips.
- Debt & Credit Limit Grid: Remaining debt calculation and restored available credit limit.
- Repayment Allocation: Principal reduction vs accrued interest breakdown.
- Utilization Bar: Visual progress track showing current vs. post-repayment utilization percentage.
- Timeline & Interest Savings: Months saved off payoff timeline and estimated total interest saved (
computePayoffProjection). - Wallet Balance Impact: Before vs. after wallet balance calculation with Low Wallet Reserve warning callout when remaining wallet balance drops below threshold.
- Typed Confirmation Guard: Integrates
requiresRepayConfirmationto enforce exact typed amount confirmation for large repayments (>= $5000).
role="dialog",aria-modal="true",aria-labelledby,aria-describedby.- Traps focus via
useFocusTrapand returns focus totriggerRefupon close. - Escape key listener to close modal.
- Tabular numbers (
.tabular-nums) for currency formatting alignment. - Supports dark mode design tokens and high-contrast (
[data-contrast="high"]) mode.