송금 컴포넌트
Senco 해외 송금 플로우용으로 만든 컴포넌트. 기본 컴포넌트 라이브러리를 핀테크 용도에 맞게 확장했다.
어떤 컴포넌트 라이브러리에도 없는 부분이다. 여기 있는 컴포넌트에는 저마다 도메인 결정이 들어 있다. "sent"와 "completed"는 어떻게 다른지, 금액은 어떻게 구성하는지, 영수증은 무엇을 증명해야 하는지. 이 결정들 때문에 이 DS는 shadcn에 테마만 씌운 것이 아니라 하나의 DS로 존재한다.
컴포넌트
| Component | Purpose | Base |
|---|---|---|
| Status badge | 송금 상태를 보여 준다 (pending, sent, completed, failed) | Badge |
| Amount input | 송금 금액을 입력하면 환산 금액을 실시간으로 보여 준다 | Input |
| Transaction row | 거래 내역 목록의 항목 하나 | — |
| Transfer stepper | 송금 플로우의 단계별 진행을 세로로 보여 준다 | Stepper |
| Camera capture | KYC용 서류/셀카 촬영 뷰파인더 | — |
| QR scanner | 스캔 라인이 움직이는 QR 코드 스캐너 | — |
| Receipt view | 절취선과 상태 헤더가 있는 송금 영수증 | Card |
| Contact card | 아바타, 이름, 국가, 송금 수단을 보여 주는 카드. 누를 수 있다 | Card |
Status badge
송금이 지금 어떤 상태인지 보여 준다. 색, 점, 아이콘, 텍스트 라벨을 함께 쓰고, 색이나 점 하나만으로 표시하지 않는다. 점은 여섯 상태 모두 모양이 같아서 색을 거들 뿐이다. 색각 이상이 있는 사용자에게 의미를 전하는 건 아이콘이다.
| Status | Color | Usage |
|---|---|---|
| Pending | Yellow | 송금을 접수했고 처리를 기다리는 중 |
| Sent | Blue | 송금을 보냈고 전달되는 중 |
| Completed | Green | 수취인이 돈을 받음 |
| Failed | Red | 송금을 완료하지 못함 |
Sent는 completed가 아니다
제품에서 가장 중요한 구분이다. Sent는 돈이 Senco에서 나갔다는 뜻이고, Completed는 수취인이 돈을 받았다는 뜻이다. 해외 송금에서는 둘 사이에 이틀이 걸리기도 하고, "sent" 상태의 송금이 받는 은행에서 실패할 수도 있다.
초록색은 completed에만 쓴다. sent에 초록색을 쓰면 아직 도착하지 않았을 수도 있는 돈을 가족이 이미 받았다고 알리는 셈이다. 신뢰를 가장 크게 잃는 실수다.
아래가 시스템의 기준 상태 표다. Status indicators는 각 상태를 언제 쓰는지 설명하고, 이 표는 각 상태를 어떻게 렌더링하는지 정한다. 둘이 어긋나면 이 표를 따른다.
| Status | Terminal | Background | Text | Dot and icon | Icon (lucide) | Label | Class |
|---|---|---|---|---|---|---|---|
| Pending | No | --ds-warning-bg | --ds-warning-text | --ds-warning-icon | Clock | Pending | .snc-badge-pending |
| Processing | No | --ds-info-bg | --ds-info-text | --ds-info-icon | LoaderCircle | Processing | .snc-badge-processing |
| Sent | No | --ds-info-bg | --ds-info-text | --ds-info-icon | ArrowUpRight | Sent | .snc-badge-sent |
| Completed | Yes | --ds-success-bg | --ds-success-text | --ds-success-icon | Check | Completed | .snc-badge-completed |
| Failed | Yes | --ds-error-bg | --ds-error-text | --ds-error-icon | TriangleAlert | Failed | .snc-badge-failed |
| Cancelled | Yes | --ds-surface | --ds-muted | --ds-muted | X | Cancelled | .snc-badge-cancelled |
사용자 입장에서 각 상태가 뜻하는 것:
| Status | Meaning |
|---|---|
| Pending | 돈은 받았고, 아직 보내지는 않았다 |
| Processing | 네트워크에서 처리하고 있다 |
| Sent | Senco에서 나갔고, 은행이 처리하고 있다 |
| Completed | 수취인이 받았다 |
| Failed | 송금되지 않았고, 이유는 이렇다 |
| Cancelled | 사용자가 취소했고, 돈은 돌려받았다 |
이 표에서 일부러 정했지만 틀리기 쉬운 부분이 세 가지 있다.
- Sent와 Processing은 똑같이 보인다. 배경도, 텍스트도, 점도 같다. 아이콘과 라벨로만 구분된다. 신호를 세 겹으로 쓰는 이유가 이것이고, 그래서 여기서는 아이콘을 뺄 수 없다.
- 색은 모두 시맨틱 토큰이라 배지가 테마에 따로 요구하는 게 없다. 텍스트는 피드백 세트의 text 토큰을, 점과 아이콘은 icon 토큰을 쓰고, 다크 테마가 이를 모두 다시 매핑한다. 이전 버전은 텍스트와 점 색을 Base 램프 단계에서 가져왔는데, 다크 모드는 규칙상 Base 램프를 다시 매핑하지 않는다. 그래서 배경만 어두워지고 텍스트는 그대로였고, 다크 모드 Pending의 대비는 1.72:1이었다.
- Cancelled는 중립 표면색을 쓴다. 중립 피드백 세트가 없어서
--ds-surface와--ds-muted를 조합했고, 둘 다 테마에서 다시 매핑된다. 페이지 배경과 칩 가장자리의 대비는 낮다(라이트 1.10:1, 다크 1.22:1). Color의 피드백 테두리와 같은 이유로 이를 허용한다. 상태를 알아보게 하는 건 칩 가장자리가 아니라 아이콘과 라벨이다.
검증된 대비
두 테마의 토큰 값으로 측정했다. 기준은 텍스트 4.5:1, 점과 아이콘 3:1이다.
| Status | Text, light | Dot and icon, light | Text, dark | Dot and icon, dark |
|---|---|---|---|---|
| Pending | 8.17:1 | 4.64:1 | 9.77:1 | 7.72:1 |
| Processing / Sent | 7.80:1 | 5.77:1 | 8.57:1 | 8.57:1 |
| Completed | 6.68:1 | 4.70:1 | 9.74:1 | 7.34:1 |
| Failed | 7.67:1 | 5.98:1 | 8.59:1 | 6.22:1 |
| Cancelled | 5.04:1 | 5.04:1 | 5.71:1 | 5.71:1 |
구조
inline 방향으로 점, 아이콘, 라벨을 놓고, 필요하면 마지막에 시각을 붙인다. 점, 아이콘, 라벨은 모든 크기에서 보인다. 점은 색을 거들고, 의미는 아이콘이 전한다.
| Part | Compact | Default |
|---|---|---|
| 라벨 | --ds-text-xs, 굵기 600 | --ds-text-sm, 굵기 600 |
| 줄 높이 | 1.6 | 1.5 |
| 패딩 | 세로 3px, 가로 10px | 세로 --ds-space-1, 가로 --ds-space-3 |
| 요소 사이 간격 | 6px | --ds-space-2 |
| 점 | 6px 원 | 8px 원 |
| 아이콘 | 12px | --ds-size-icon-sm (16px) |
| Radius | --ds-radius-full | --ds-radius-full |
| 시각 | 렌더링하지 않는다. 배지가 들어 있는 행이 보여 준다 | --ds-text-xs, --ds-muted 굵기 400 |
| 줄바꿈 | white-space: nowrap | white-space: nowrap |
compact의 패딩(3px, 10px)과 간격(6px)은 4px 간격 스케일에서 벗어난다. 60px 거래 행에 맞추려면 이 값이어야 한다. 맞지 않는 스케일 값으로 반올림하는 대신 알려진 공백에 기록해 둔다.
Processing 아이콘은 배지 안에서 돌지 않는다. 배지는 송금 상태를 알려 줄 뿐, 사용자가 무언가를 기다리고 있다는 로딩 표시가 아니다. 목록 가득 아이콘이 돌아가면 움직임만 있고 정보는 없다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Compact | layout | 거래 목록 행. 점, 아이콘, 라벨 |
| Default | layout | 거래 상세 헤더. 점, 아이콘, 라벨, 시각 |
API
type TransferStatus =
| "pending" | "processing" | "sent" // non-terminal
| "completed" | "failed" | "cancelled"; // terminal
interface StatusBadgeProps {
status: TransferStatus;
since?: Date; // entered-this-status timestamp; renders "Pending since 10:42"
size?: "compact" | "default"; // default: "compact" — compact is the list-row form
announce?: boolean; // default: false — true only where this badge owns the live announcement
subject?: string; // required when announce is true — "Transfer to Maria Santos"
}
| Prop | Contract |
|---|---|
status | 유니언에 다른 값은 없다. "unknown"도 없다. 백엔드가 분류하지 못한 상태는 pending으로 둔다 |
since | 크기와 상관없이, 진행 중인 상태에는 모두 필요하다. 언제부터인지 모르는 "Pending"은 "멈춤"과 구분이 안 된다. default 크기는 배지 안에 시각을 렌더링하고, compact 크기는 렌더링하지 않는다. 그래서 compact는 Transaction row의 메타 줄처럼 같은 시각을 따로 보여 주는 컨테이너 안에서만 쓴다. 형식은 Typography를 따른다. Pending since 10:42, Completed · 12 Mar, 10:42 |
announce | 배지와 부모 행이 둘 다 소리 내 알리면 안 된다. 거래 목록에서는 행이, 상세 화면에서는 배지가 알린다 |
subject | 안내에는 어떤 송금인지 밝힌다. "Transfer to Maria Santos is now completed". 송금이 여섯 개 있는 목록에서 "Completed"만 들리면 어느 송금인지 알 수 없다 |
안내는 polite로 한다. 단, failed로 바뀔 때만 assertive다. 사용자가 다음에 할 일이 달라지는 상태 변화는 실패뿐이다.
렌더링은 위의 기준 상태 표를 따른다. 컴포넌트에 별도의 색 로직은 없다.
상태
위의 여섯 가지가 곧 배지의 상태다. 배지는 컨트롤이 아니므로 hover, focus, pressed 상태가 없다. since는 배지 안에 렌더링되고, 값이 바뀌면 다시 알리지 않고 그 자리에서 갱신한다.
동작
기준 표대로 렌더링하고, 자체 색 로직은 없다. since는 다시 알리지 않고 그 자리에서 갱신한다. 상태 변화는 announce가 켜져 있을 때만 알리므로 행과 배지가 겹쳐 읽히는 일이 없다.
콘텐츠
문장형 대소문자로, 되도록 한 단어로 쓴다. "COMPLETED"나 "Successfully completed"가 아니라 "Completed".
접근성
색, 아이콘, 텍스트 라벨 세 가지 신호를 반드시 함께 쓴다. 녹색맹(제2색맹)에게는 pending의 노란색과 completed의 초록색이 거의 같아 보인다. 그래도 구분되는 건 아이콘과 글자다. Status indicators 참고.
키보드
포커스를 받지 않고, 누를 수도 없다. 상호작용은 배지가 들어 있는 행이나 상세 화면이 맡는다.
토큰
--ds-warning-bg · -text · -icon · --ds-info-* · --ds-success-* · --ds-error-* · --ds-surface · --ds-muted · --ds-radius-full · --ds-text-xs · --ds-text-sm. .snc-badge와 .snc-badge-{status}로 렌더링한다
Amount input
큰 숫자 입력과 통화 태그를 하나로 묶었다. 아래에 실시간 환율로 환산한 금액을 보여 준다.
- 숫자만 받고 서식은 자동으로 맞춘다
- 통화 태그를 눌러 통화를 바꾼다
- 환율은 실시간으로 갱신된다
- 법정화폐와 크립토 금액을 모두 지원한다
구조
┌──────────────────────────────────────┐
│ You send │ label, --ds-text-sm
│ ┌────────────────────┐ ┌────────┐ │
│ │ 1,250.00 │ │ 🇺🇸 USD ▾│ │ --ds-text-display / 700
│ └────────────────────┘ └────────┘ │ currency tag, tappable
│ ≈ 2,062,500.00 NGN │ --ds-text-sm, --ds-muted
│ Rate 1 USD = 1,650.00 NGN │ --ds-text-xs, --ds-muted
└──────────────────────────────────────┘
| Part | Token |
|---|---|
| 필드 | .snc-amount-field, --ds-input-height |
| 금액 | --ds-text-display, 굵기 700, tabular-nums |
| 통화 태그 | .snc-amount-currency-tag, 48px 터치 영역 |
| 환산 금액 | .snc-amount-conversion, --ds-muted |
입력 중인 값은 한 가지 크기의 텍스트 하나로 보여 준다. Typography에서 센트 단위를 작게 쓰는 규칙은 표시용 금액에만 적용하고, 입력 중인 금액에는 적용하지 않는다. <input> 안에서는 글자 크기를 두 가지로 렌더링할 수 없다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Hero | layout | 송금 화면의 메인 금액. --ds-text-display, 굵기 700, 줄 높이 1.2, 필드 최소 높이 64px, 위아래 --ds-space-2 패딩 |
| Default | layout | 보조 금액 필드 |
API
interface CurrencyOption {
code: string; // ISO 4217, or a product-owned code for crypto
kind: "fiat" | "crypto"; // decides minor-unit source and whether a flag is expected
name: string; // "Nigerian Naira" — also the spoken form in descriptions
minorUnits: number; // 2 USD, 0 JPY, 8 BTC — see Tokens > Currency minor units
flag?: string; // ISO 3166-1 alpha-2; omitted for crypto
keywords?: string[]; // extra search terms — country names, "naira"
rateLabel?: string; // pre-formatted, shown in the picker — "1 USD = 1,650.00"
}
interface RateQuote {
rate: number;
quotedAt: Date;
expiresAt: Date; // quotedAt + 60s fiat, + 15s crypto
}
interface AmountInputProps extends FieldBase { // label, hint, error, required, disabled, readOnly, name, onBlur
value: string; // string, not number: a trailing "." must survive a render
onValueChange: (value: string) => void;
currency: CurrencyOption;
currencies: CurrencyOption[];
suggestedCurrencies?: string[]; // codes, rendered first in the picker — recent and likely corridors
onCurrencyChange: (currency: CurrencyOption) => void;
targetCurrency?: CurrencyOption; // omit to hide the conversion line
quote?: RateQuote;
quoteState?: "idle" | "loading" | "live" | "expiring" | "expired" | "error"; // default: derived — see below
onRefreshQuote?: () => void;
max?: string; // balance or limit; exceeding it errors without clearing the value
size?: "default" | "hero"; // default: "hero" on the send screen
locale?: string; // default: the app locale — drives separators and decimal mark
}
| Prop | Contract |
|---|---|
value | 컴포넌트가 값을 지우는 일은 없다. 검증에 실패하면 error만 설정하고 값은 그대로 둔다. 형식은 표준 십진 문자열이다. ASCII 숫자, .는 많아야 하나, 그 뒤는 minorUnits자리까지, 자릿수 구분자와 기호는 넣지 않는다("1250.5"). 서식은 화면 표시에만 쓴다. max도 같은 형식이고, 통화는 currency와 같다 |
quote | 견적 만료는 말없이 값만 바꾸는 게 아니라 화면에 상태로 보여 준다. expiresAt이 되면 환산 금액에 취소선이 그어지고 onRefreshQuote가 나타난다 |
quoteState | 생략하면 다음 규칙으로 정한다. targetCurrency가 없거나 값이 비었으면 → idle. quote가 없으면 → loading. 현재 시각이 quotedAt과 expiresAt의 중간 전이면 → live, 중간을 지났으면 → expiring. expiresAt이 되었거나 지났으면 → expired. error는 자동으로 정하지 않는다. 견적 요청과 10초 타임아웃(Tokens > Timing and threshold values)은 호출하는 쪽이 관리하고, 실패하거나 시간이 초과되면 quoteState="error"로 설정한다 |
FieldBase | Amount input도 다른 필드와 똑같이 폼에 참여한다. 제출에 쓰는 name, 공통 규칙을 따르는 disabled와 readOnly, 검증 시점을 잡는 onBlur |
size | 여기의 "hero"와 Currency input의 hero 크기는 같은 모양이다. 송금 화면에는 Amount input을 쓰고, Currency input의 hero는 통화가 하나로 정해진 받는 금액 필드에 쓴다 |
캐럿 유지
입력하는 동안 필드가 서식을 계속 다시 맞추는데, 그때 캐럿이 튀면 안 된다. 규칙을 명시해 두지 않으면 구현마다 사용자가 실제로 겪는 세 가지 경우에서 동작이 달라진다.
- 구분자를 빼고 캐럿 앞에 있는 숫자 개수를 센다.
- 값 전체의 서식을 다시 맞춘다.
- 같은 개수의 숫자 뒤에 캐럿을 둔다. 중간 위치가 화면에 그려지지 않도록 layout effect에서 복원한다.
| Case | Required behaviour |
|---|---|
| 구분자 바로 뒤에서 백스페이스 | 구분자가 아니라 그 앞의 숫자를 지운다 |
| 붙여 넣기 | 숫자와 첫 번째 소수점(로케일의 소수점 또는 .)만 남기고 나머지는 걸러 낸다. minorUnits 자리까지만 남기고, 캐럿은 끝에 둔다. "$1,250.50"은 1250.50이 된다 |
| 전체 선택 후 입력 | 값 전체를 새로 입력한 값으로 바꾼다. 캐럿은 끝에 둔다 |
상태
| State | Rendering |
|---|---|
| Empty | 환산 줄에 "Enter an amount to see the {code} equivalent"를 보여 준다 |
| Typing | 서식을 다시 맞춘다. 캐럿은 위 규칙대로 유지한다 |
| Quote loading | 환산 줄에 인라인 로딩 표시 |
| Quote live | 환산 금액과 환율 줄 |
| Quote expiring | 유효 시간의 절반이 지나면 남은 시간을 보여 준다 |
| Quote expired | 환산 금액에 취소선을 긋고, 새로 고침 액션을 띄운다 |
| Quote error | 환율 줄 자리에 다시 시도 액션을 보여 준다. 입력한 금액은 건드리지 않는다 |
Over max | 인라인 에러를 띄우고, 값은 그대로 둔다 |
동작
type="number"가 아니라inputmode="decimal"을 쓴다. number 입력은 스피너 화살표가 붙고,e와+를 받고, 브라우저마다 동작이 다르다.- 입력하는 동안 자릿수 구분자가 들어가고, 서식이 바뀌어도 캐럿 위치는 그대로다.
- 소수 자릿수는 통화의 최소 단위를 따른다. USD는 2, JPY는 0, BTC는 8. 크립토 금액과 법정화폐 금액은 라벨만 바꾼 같은 필드가 아니다.
- 환산 줄은 키 입력마다 갱신하지 않고 debounce를 건다.
- 잔액이나 한도를 넘으면 인라인 에러를 보여 주고 입력한 값은 그대로 둔다. 금액이 너무 크다고 입력을 지워 버리면, 사용자는 방금 계산해서 넣은 금액을 잃는다.
통화 선택기
통화 태그를 누르면 Sheet가 열린다. 이 컴포넌트에서 쓰는 선택기는 이것 하나다.
────────────────────────────────────────
Choose currency ✕
🔍 Search currency or country
Suggested
🇳🇬 NGN Nigerian Naira 1 USD = 1,650.00
🇵🇭 PHP Philippine Peso 1 USD = 56.20
All currencies
🇦🇪 AED UAE Dirham 1 USD = 3.67
…
────────────────────────────────────────
| Part | Specification |
|---|---|
| 컨테이너 | Sheet. --ds-bp-mobile 미만은 side="bottom", 이상은 side="end". 크기는 모바일 lg, 데스크톱 default |
| 제목 | "Choose currency". Sheet에 꼭 필요한 접근 가능한 이름이다 |
| 검색 | 맨 위의 입력. code, name, keywords를 대소문자 구분 없이 찾는다. 라벨은 화면에서 숨기고 placeholder와 같은 문구로 한다(Copy 참고). 자동 포커스는 포인터 기기에서만 한다. 터치 기기에서 포커스를 주면 사용자가 훑어보려던 목록을 키보드가 가린다 |
| Suggested | suggestedCurrencies를 받은 순서대로 보여 준다. 검색어가 있으면 숨긴다 |
| All currencies | currencies 전체를 code 알파벳순으로 |
| 행 | 국기(장식용, 28px) · 코드 --ds-text-base 600 · 이름 --ds-text-sm --ds-muted · inline-end에 rateLabel --ds-text-sm tabular-nums. 높이 --ds-size-list-item (60px) |
| 선택된 행 | inline-end에 --ds-primary 체크 아이콘, aria-selected="true" |
| 결과 없음 | 목록 자리에 검색어와 함께 "No currency matches '{query}'"를 보여 준다 |
| Behaviour | Rule |
|---|---|
| 행 선택 | 시트를 닫고, onCurrencyChange를 호출하고, 포커스를 통화 태그로 돌려준다. 입력한 금액은 그대로 두고 새 minorUnits에 맞춰 서식만 다시 맞춘다 |
| 통화 변경 후 | onCurrencyChange가 호출되면 호출하는 쪽이 quote를 비우고 새 견적을 요청한다. quote가 없으므로 상태는 loading이 된다 |
| 고르지 않고 닫기 | Escape, 닫기 버튼, 배경 탭, 아래로 스와이프. 통화는 바뀌지 않는다 |
| 다시 열기 | 검색어는 비우고, 목록은 선택된 행 위치로 스크롤한다 |
| Key | Action |
|---|---|
통화 태그에서 Enter / Space | 시트를 연다 |
| 입력 | 검색 입력으로 들어간다 |
검색에서 ↓ | 목록으로 내려간다 |
↑ ↓ | 활성 행을 옮긴다 |
Enter | 활성 행을 선택한다 |
Esc | 바꾸지 않고 닫는다 |
목록은 다이얼로그 안에서 listbox 패턴을 따른다. 목록에 role="listbox", 행에 role="option"을 달고, 검색 입력에서 aria-activedescendant로 활성 행을 가리킨다. 검색어가 바뀌면 결과 개수를 polite로 알린다.
구조는 listbox 하나 안에 "Suggested"와 "All currencies" 라벨이 붙은 role="group" 두 개를 둔다. 검색어가 있으면 두 번째 그룹만 보인다. ↑ ↓는 끝에서 처음으로 돌아가지 않고, Home / End는 첫 행과 마지막 행으로 간다. 시트가 열리면 현재 선택된 통화가 활성 행이 된다. 활성 행은 --ds-surface 배경을 쓰고, 선택된 행에는 Check 아이콘이 붙는다.
환율의 신선도
견적 환율에는 유효 시간이 있다. 만료되면 환산 줄에 취소선이 그어지고 새로 고침 액션이 나타난다. 지난 숫자를 현재 값처럼 보여 주지 않는다. 견적을 받은 뒤 확인하기 전에 환율이 크게 바뀌면, 확인 단계에서 견적을 다시 받고 바뀌었다는 사실을 알린다. 새 환율로 말없이 결제하지 않는다.
콘텐츠
라벨에는 무슨 금액인지 적는다. "You send", "They receive"처럼 쓰고, "Amount"라고만 쓰지 않는다.
카피
| Moment | Text |
|---|---|
| 선택기 검색 라벨(화면에서 숨김)과 placeholder | Search currency or country |
| 선택기 결과 개수(스크린 리더 안내) | {n} currencies |
| 선택기 결과 없음 | No currency matches "{query}" |
| 비어 있는 환산 줄 | Enter an amount to see the {code} equivalent |
| 견적 불러오는 중 | Getting today's rate |
| 견적 만료 임박 | Rate held for {s}s |
| 견적 만료와 액션 | Rate expired · Refresh rate |
| 견적 에러와 액션 | Couldn't get a rate · Try again |
max 초과 | You can send up to {max} {code} |
| 환율 갱신(스크린 리더 안내) | Rate updated: 1 {from} = {rate} {to} |
접근성
- 라벨은 필드 위에 보이게 둔다. placeholder를 라벨 대신 쓰지 않는다.
- 환산 줄과 환율은
aria-describedby로 연결해서 필드와 함께 읽히게 한다. - 환율 갱신은 polite로, throttle을 걸어 알린다. 30초마다 바뀌는 환율을 assertive 영역에 두면 사용자를 계속 방해한다.
- 통화 태그는
<button>이다. 화면에 보이는 텍스트는 코드("USD")이고, 화면에서 숨긴 뒷부분이 이름을 완성한다("USD, US Dollar, change currency"). 그래서 접근 가능한 이름 안에 보이는 텍스트가 들어간다(SC 2.5.3). 선택기가 열려 있는 동안aria-haspopup="dialog"와aria-expanded를 단다. - 필드 값은 입력한 그대로 읽힌다. 텍스트 입력 값에는 읽는 방식을 따로 지정할 수 없다. 그래서 통화는 필드 설명으로 전달한다.
aria-describedby가 참조하는 순서는 에러(있을 때), 통화의name, 환산 줄, 환율 줄, 힌트다. - 환산 줄은 화면용 짧은 표기(
≈ 2,062,500.00 NGN)를aria-hidden으로 렌더링하고, 옆에 화면에서 숨긴 읽기용 문장("approximately 2,062,500.00 Nigerian Naira")을 둔다. "≈"와 통화 코드는 스크린 리더마다 읽는 방식이 제각각이기 때문이다.
포커스했을 때 VoiceOver가 읽는 내용:
You send, 1,250.00, text field, US Dollar, approximately 2,062,500.00 Nigerian Naira, rate 1 US Dollar equals 1,650.00 Nigerian Naira
입력이 아니라 표시만 하는 금액(잔액, 영수증, 수수료 행)의 통화 읽기 규칙은 Typography에 있다.
키보드
| Key | Action |
|---|---|
Tab | 금액 필드로, 그다음 통화 태그로 이동한다 |
통화 태그에서 Enter / Space | 통화 선택기를 연다 |
| 숫자와 로케일 소수점 | 입력된다(.는 언제나 소수점으로 받는다). 다른 문자는 입력되지 않고, 기존 값도 지우지 않는다 |
Ctrl/Cmd+V | 붙여 넣기 규칙대로 정리하고, 캐럿은 끝에 둔다 |
토큰
.snc-amount-input · .snc-amount-field · .snc-amount-currency-tag · .snc-amount-conversion · .snc-currency-code · .snc-currency-flag
C$10 CAD — C$10,000 CAD
Transaction row
모바일 거래 내역에서 송금 한 건을 보여 준다. 수취인 아바타, 이름, 날짜, 금액, 통화, 상태 배지가 들어간다.
- 행을 누르면 송금 상세로 간다
- 금액은 훑어보기 쉽게 오른쪽에 정렬한다
- 상태 배지는 스크롤하지 않아도 항상 보인다
구조
┌────────────────────────────────────────────┐
│ (🇳🇬) Maria Santos -250.00 USD │ 60px min-height
│ Bank transfer · 12 Mar [Completed] │
└────────────────────────────────────────────┘
36px 14px/600 name 16px/700 amount
flag 12px/400 meta status badge
| Part | Token |
|---|---|
| 행 높이 | --ds-size-list-item (60px) |
| 패딩 | 위아래 --ds-space-3, 좌우 --ds-space-4 |
| 국기 | --ds-size-avatar (36px) |
| 이름 | .snc-tx-name, --ds-text-sm / 600 |
| 메타 | .snc-tx-meta, --ds-text-xs, --ds-muted |
| 금액 | .snc-tx-amount, --ds-text-base / 700, tabular-nums |
변형
| Variant | Dimension | Use |
|---|---|---|
| Default | layout | 모바일 거래 내역 |
| Dense | layout | 대시보드 "recent activity". 5행, 송금 수단 줄 없음 |
API
interface TransactionRowProps {
recipientName: string;
countryCode: string; // ISO 3166-1 alpha-2 — renders the flag, decorative
method: string; // "Bank transfer", "Mobile money"
amount: string; // pre-formatted, including the currency code
direction: "out" | "in";
status: TransferStatus;
date: Date;
href: string; // the row is one link; there is no second action inside it
}
상태
| State | Rendering |
|---|---|
| Default | --ds-bg |
| Hover | --ds-surface |
| Focus-visible | 안쪽으로 3px 실선 --ds-focus 외곽선 |
| Pressed | --ds-surface, 그림자 높이는 바꾸지 않는다 |
hover나 누름 상태에서는 행 배경이 Cancelled 배지 배경과 같아져서 칩 가장자리가 사라진다(1.00:1). 이건 허용한다. 배지의 아이콘과 라벨은 같은 배경에서도 5.04:1을 유지하고, 상태를 전하는 건 칩 가장자리가 아니라 아이콘과 라벨이다.
동작
- 행 전체가 컨트롤 하나다. 안에 보조 액션을 두지 않는다. 누를 수 있는 곳과 없는 곳이 섞인 행에서는 사용자가 엉뚱한 곳을 누른다.
- 금액에는 통화 코드와 방향(보냄/받음)을 붙인다. 여러 통화가 섞인 내역에서 숫자만 있으면 알아볼 수 없다.
- 상태는 펼치지 않아도 보인다. 이 행이 있는 이유가 "도착했나?"에 답하는 것이기 때문이다.
- Save-Data 모드나 오프라인에서는 국기 대신 국가 코드를 넣은 원을 보여 준다. 그래도 중요한 정보는 하나도 빠지지 않는다.
콘텐츠
금액에는 부호와 통화 코드를 붙인다. 메타 줄에는 송금 수단, 날짜 순으로 적는다. 송금이 늦어질 때 사용자가 먼저 확인하는 게 수단이기 때문이다.
접근성
- 행 내용을
<button>이나<a>로 감싸고, 접근 가능한 이름은 문장처럼 읽히게 하나로 만든다. "Maria Santos, 250 dollars, sent 12 March, completed." - 국가는 이미 접근 가능한 이름에 들어 있으므로 국기는 장식(
alt="")으로 둔다. - 상태 배지는 따로 포커스를 받지 않고, 행 이름에 포함된다.
- 높이 60px이라 최소 터치 영역 48px을 넘는다.
키보드
| Key | Action |
|---|---|
Tab | 행 전체에서 한 번만 멈춘다 |
Enter | 송금 상세를 연다 |
토큰
.snc-tx-row · .snc-tx-avatar · .snc-tx-name · .snc-tx-meta · .snc-tx-amount · .snc-tx-currency · .snc-tx-details · .snc-tx-value
Transfer stepper
송금 플로우의 단계를 세로로 보여 준다. 완료한 단계, 지금 단계, 남은 단계를 선으로 잇는다.
- 단계: Send → Review → Confirm
- 완료한 단계에는 체크 아이콘을 보여 준다
- 지금 단계는 Primary 색으로 강조한다
- 남은 단계는 흐리게 보여 준다
구조
단계마다 점, 연결선, 라벨이 있고, 필요하면 시각을 붙인다. 완료한 단계의 점은 체크가 들어간 24px, 지금 단계와 남은 단계의 점은 12px이다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Horizontal | layout | 송금 플로우 맨 위 |
| Vertical | layout | 거래 상세의 타임라인. 시각을 함께 보여 준다 |
API
interface TransferStepperProps {
steps: Array<{ id: string; label: string }>; // 3 maximum
currentIndex: number;
completedIndices: number[];
errorIndex?: number;
onStepSelect?: (index: number) => void; // called only for completed steps
orientation?: "horizontal" | "vertical"; // default: "horizontal"
}
남은 단계는 aria-disabled이고 onStepSelect로 넘기지 않는다. 컴포넌트가 알아서 다음 단계로 넘어가지 않는다.
상태
| State | Dot | Line | Token |
|---|---|---|---|
| Completed | 초록 채움, 체크 아이콘 | 초록 | --ds-timeline-completed-dot |
| Active | --ds-primary 채움, 펄스 애니메이션 | 기본 회색 | --ds-timeline-active-dot, --ds-timeline-pulse-color |
| Upcoming | --ds-gray-300 외곽선 | 기본 회색 | --ds-timeline-future-dot |
지금 단계의 점에는 펄스 애니메이션이 있다. prefers-reduced-motion에서는 애니메이션을 멈추지만, 채움 색, 아이콘, 단계 라벨로 상태는 그대로 전달된다. 움직임만으로 상태를 알리지 않는다.
동작
- 완료한 단계는 누를 수 있고, 입력한 데이터를 그대로 둔 채 그 단계로 돌아간다. 남은 단계로는 갈 수 없다.
- 스테퍼는 알아서 넘어가지 않는다. 사용자가 액션을 해야만 다음 단계로 간다.
- 단계는 최대 세 개다. 네 번째 단계가 필요하다면 스테퍼를 늘릴 게 아니라 플로우를 다시 설계해야 한다. Design principles 참고.
콘텐츠
단계 라벨은 한두 단어로 쓴다. "Send", "Review", "Confirm". 세로 변형의 시각은 상대 시간이 아니라 정확한 시각으로 쓴다. "2 hours ago"가 아니라 "14:32, 12 Mar".
접근성
- 순서 있는 목록으로 만들어서 몇 번째 단계인지, 전체가 몇 단계인지가 구조로 전달된다.
- 지금 단계에
aria-current="step"을 단다. - 남은 단계는 탭 순서에서 빼지 않고
aria-disabled로 둔다.
키보드
| Key | Action |
|---|---|
Tab | 스테퍼 전체에서 한 번만 멈춘다 |
← → | 갈 수 있는 단계 사이를 이동한다 |
Enter | 포커스된 완료 단계로 돌아간다 |
토큰
--ds-timeline-completed-dot · --ds-timeline-completed-icon · --ds-timeline-active-dot · --ds-timeline-future-dot · --ds-timeline-line-completed · --ds-timeline-line-default · --ds-timeline-pulse-color
Camera capture
KYC 촬영용 뷰파인더 컴포넌트. 변형은 document(모서리 표시가 있는 신분증 프레임)와 selfie(원형 프레임) 두 가지다. 보여 주기용 패턴으로 디자인했고, 네이티브 카메라 API는 쓰지 않는다.
- Document 변형: 856:540 비율(표준 신분증)의 사각 프레임과 L자 모서리 표시
- Selfie 변형: 둥근 테두리의 원형 프레임(지름 160px)
- 셔터 버튼: 64px 이중 원(바깥 링 + 안쪽 채움)
- 촬영하면 "Use Photo"와 "Retake" 버튼으로 바뀐다
- UI 자체가 어두워서 다크 모드용으로 따로 덮어쓸 필요가 없다
크롬이 항상 어두운 이유
카메라 화면은 두 테마 모두 --ds-camera-bg(#111827)를 쓴다. 라이트 모드에서 뷰파인더 색이 뒤집히면 사용자는 익숙한 기준을 잃고, 얻는 건 없다. 이 화면의 중심은 인터페이스가 아니라 카메라 영상이다. 테마 재매핑에서 공식적으로 허용한 예외 두 가지 중 하나이고, Tokens에 기록해 두었다.
구조
항상 어두운 배경 위에 프레임 오버레이, 셔터 위의 힌트 텍스트, 64px 이중 원 셔터를 두고, 맨 아래에 직접 업로드 링크를 둔다.
변형
| Variant | Dimension | Frame |
|---|---|---|
document | type | L자 모서리 표시가 있는 856:540 사각형 |
selfie | type | 160px 원 |
API
interface CameraCaptureProps {
variant: "document" | "selfie";
captured?: string; // data URL or object URL; presence switches to the review state
onCapture: () => void;
onRetake: () => void;
onConfirm: () => void;
onManualUpload: () => void; // required — a capture-only flow excludes users who cannot operate a camera
hint?: string; // shown before capture, not after rejection
}
상태
| State | Rendering |
|---|---|
| Framing | 뷰파인더, 프레임 표시, 힌트 텍스트 |
| Aligned | 프레임 표시가 --ds-camera-frame-active로 바뀐다 |
| Captured | 촬영한 이미지와 "Use photo", "Retake". 둘 다 미리 선택하지 않는다 |
| Unavailable | 이유를 알리고 직접 업로드로 안내한다 |
동작
- 프레임은 서류 모양에 맞춘다(신분증은 856:540, 셀카는 원형). 프레임이 서류와 맞지 않으면 올린 사진이 인증에서 반려된다.
- 촬영한 사진은 제출 전에 확인할 수 있다. "Use photo"와 "Retake"를 모두 두고, 어느 쪽도 미리 선택하지 않는다.
- 안내 문구("Fit your ID inside the frame")는 반려된 뒤 에러로 띄우지 않고 촬영 전에 보여 준다.
콘텐츠
힌트에는 어떻게 찍어야 하는지를 적는다("Fit your ID inside the frame, all four corners visible"). 찍고 난 뒤 에러로 알리지 말고 찍기 전에 보여 준다.
접근성
- 셔터 버튼에는 변형마다 다른
aria-label을 단다. "Capture"가 아니라 "Take photo of ID", "Take selfie". - 프레임 모서리 표시는 장식이므로
aria-hidden으로 둔다. - 촬영에 성공하면 polite로 알린다.
- 카메라를 다룰 수 없는 사용자를 위해 직접 업로드 경로를 둔다. 촬영으로만 진행하는 KYC 플로우는 운동·시각 장애가 있는 사람이 제품을 아예 쓸 수 없게 만든다.
키보드
| Key | Action |
|---|---|
Tab | 셔터로, 그다음 직접 업로드로 이동한다 |
Enter / Space | 촬영한다 |
토큰
--ds-camera-bg · --ds-camera-overlay · --ds-camera-frame · --ds-camera-frame-active · --ds-camera-capture-bg · --ds-camera-capture-border · --ds-camera-retake-bg · --ds-camera-hint
QR scanner
스캔 라인이 움직이는 QR 코드 스캐너 뷰파인더. 스캔 중 상태와 성공 상태가 있고, 직접 입력 링크를 붙일 수 있다.
- 정사각형 스캔 창: 부모 폭의 65%, 최대 280px
- L자 모서리 표시 4개(28px)
- 스캔 라인이 위에서 아래로 반복해서 움직인다
- 성공하면 스캔 라인 자리에 초록 체크 아이콘이 뜨고, 모서리가 초록색으로 바뀐다
- 키보드 아이콘이 붙은 직접 입력 버튼
- UI 자체가 어두워서 다크 모드용으로 따로 덮어쓸 필요가 없다
직접 입력은 선택이 아니라 필수다
직접 입력 링크는 예비용이 아니라 컴포넌트의 일부다. 화면이 깨졌을 때, 어두울 때, 인쇄된 코드가 훼손됐을 때, 휴대폰을 가만히 들고 있기 어려운 사람이 쓸 때 스캔은 실패한다. 모두 Senco 사용자에게 흔한 일이다. 직접 입력할 방법이 없는 스캐너에서는 사용자가 더 나아갈 수 없다.
구조
항상 어두운 배경 위에 정사각형 스캔 창(폭 65%, 최대 280px), 28px 모서리 표시 네 개, 움직이는 스캔 라인, 힌트, 직접 입력 버튼을 둔다.
변형
하나. 직접 입력으로 넘어갈 수 없는 스캐너는 변형이 아니라 결함이다.
API
interface QRScannerProps {
state: "scanning" | "success" | "error";
onScan: (value: string) => void;
onManualEntry: () => void; // required, not a fallback
hint?: string;
errorMessage?: string;
}
상태
| State | Rendering |
|---|---|
| Scanning | 스캔 라인이 반복해서 움직인다. 모서리 표시는 중립색 |
| Success | 모서리 표시가 초록색으로 바뀌고, 스캔 라인 자리에 체크 아이콘이 뜨고, 성공을 알린다 |
| Error | 메시지와 다시 시도 버튼. 직접 입력은 계속 할 수 있다 |
| Reduced motion | 스캔 라인이 멈춰 있다. 스캔과 안내는 그대로 동작한다 |
동작
- 스캔하는 동안 스캔 라인은 계속 움직이고, 성공하면 멈춘다.
- 성공하면 모서리 표시가 초록색으로 바뀌고, 체크 아이콘을 보여 주고, 소리로도 알린다. 색만으로는 성공을 확인할 수 없다.
prefers-reduced-motion에서는 스캔 라인이 멈춘다. 스캔과 안내는 그대로 동작한다.
콘텐츠
안내에는 무엇을 비춰야 하는지 적는다. 직접 입력 컨트롤에는 누르면 무엇을 하는지 적는다. "Having trouble?"이 아니라 "Enter the address instead".
접근성
- 스캐너 영역에는 접근 가능한 이름과 텍스트 안내를 둔다.
- 성공과 실패는 polite 라이브 영역으로 알린다.
- 직접 입력 컨트롤은 48px 터치 영역을 갖춘 실제 버튼이다.
- 움직이는 스캔 라인은 장식이므로
aria-hidden으로 둔다.
키보드
| Key | Action |
|---|---|
Tab | 직접 입력 컨트롤로 이동한다 |
Enter | 직접 입력을 연다 |
토큰
--ds-qr-bg · --ds-qr-overlay · --ds-qr-frame · --ds-qr-scan-line · --ds-qr-hint · --ds-qr-manual-link
Receipt view
송금 영수증을 보여 주는 복합 컴포넌트. 상태 헤더, 절취선, 키-값 행, 액션 푸터로 이뤄진다.
하위 컴포넌트:
ReceiptView— 상태 헤더가 있는 최상위 컨테이너ReceiptViewSection— 행을 묶는 섹션, 제목은 선택ReceiptViewRow—dl/dt/dd시맨틱을 쓰는 라벨 + 값 한 쌍ReceiptViewActions— 액션 버튼이 있는 푸터
구조:
- 헤더: 상태 아이콘 + 제목 + 부제(날짜)
- 절취선: CSS
radial-gradient로 만든 물결 모양 가장자리 - 본문: 섹션별 키-값 행
- 푸터: 다운로드/공유 버튼
영수증이 담아야 하는 것
영수증은 사용자가 은행, 가족, 고객 지원팀에 보내는 문서다. 제품에서 앞뒤 맥락 없이 그 자체로 완결되어야 하는 화면은 이것뿐이다.
| Field | Why |
|---|---|
| 참조 번호 | 고객 지원팀이 조회할 수 있는 유일한 값 |
| 상태와 시각 | 어떤 상태였고, 언제였는지 |
| 보낸 금액 + 통화 | 얼마가 나갔는지 |
| 받은 금액 + 통화 | 얼마가 도착했는지 |
| 적용된 환율 | 지금 환율이 아니라 송금을 실행한 시점의 환율 |
| 수수료, 항목별 | 송금 수수료와 환전 마진을 나눠서 |
| 보낸 사람과 받는 사람 | 계좌에 적힌 이름 그대로 |
| 결제 수단 | 어떤 송금 경로를 썼는지 |
영수증에 보여 주는 환율은 실제로 적용된 환율이고, 이후에도 바뀌지 않는다. 오늘 환율로 다시 그려지는 영수증은 영수증이 아니다.
구조
아이콘, 제목, 날짜가 있는 상태 헤더, 물결 모양 절취선, 라벨-값 행으로 된 본문 섹션, 다운로드와 공유 버튼이 있는 푸터 순서다.
변형
| Variant | Dimension | Use |
|---|---|---|
screen | type | 앱 화면. 시스템 폰트, 액션 있음 |
export | type | PDF나 이메일. Arial 폰트 스택, 액션 없음 |
API
interface ReceiptViewProps {
status: TransferStatus;
reference: string; // the only value support can search on
timestamp: Date;
sections: Array<{
title?: string;
rows: Array<{ label: string; value: string; emphasis?: boolean }>;
}>;
onDownload?: () => void;
onShare?: () => void;
rendering?: "screen" | "export"; // default: "screen"; "export" switches to the documentation font stack
}
값은 서식을 미리 맞춘 문자열로 받고, 환율은 실제로 적용된 환율을 보여 준다. 렌더링할 때 환율을 다시 계산하는 영수증은 영수증이 아니다.
상태
| State | Rendering |
|---|---|
| Completed | 체크 아이콘이 있는 초록색 상태 헤더 |
| Pending / Sent | 해당 피드백 세트 색의 상태 헤더 |
| Failed | 에러 헤더와 실패 이유. 타임라인은 실패한 단계에서 멈춘다 |
| Export | 문서용 폰트 스택, 상호작용 컨트롤 없음 |
동작
- 내보내는 영수증은
system-ui대신 문서용 폰트 스택(Arial)을 쓴다. 그래야 PDF가 어디서 열어도 똑같이 보인다. Typography 참고. - 절취선은 이미지가 아니라 CSS 그라디언트다. 이미지가 하나도 로드되지 않아도 영수증은 온전히 보인다.
- 다운로드와 공유는 서로 다른 액션이다. 공유는 OS 공유 시트를 띄우고, 다운로드는 파일을 저장한다.
콘텐츠
위의 영수증 항목 표에 있는 행은 모두 필수다. 참조 번호가 없으면 고객 지원에 쓸 수 없는데, 영수증의 쓸모는 대부분 거기에 있다.
접근성
- 최상위는 시맨틱
<article>이고, 키-값 쌍에는<dl>/<dt>/<dd>를 쓴다. 그래야 스크린 리더가 두 열을 따로따로 읽지 않고 라벨과 값을 짝지어 읽는다. - 절취선은 장식이므로
aria-hidden으로 둔다. - 금액은
tabular-nums를 쓰고, 통화와 함께 읽힌다. - 상태 아이콘은
aria-hidden이다. 상태는 헤더의 텍스트로 전달한다.
키보드
| Key | Action |
|---|---|
Tab | 다운로드로, 그다음 공유로 이동한다 |
Enter | 실행한다 |
토큰
--ds-receipt-bg · --ds-receipt-border · --ds-receipt-header-bg · --ds-receipt-icon-success · --ds-receipt-label · --ds-receipt-value · --ds-receipt-divider · --ds-receipt-footer-bg · --ds-receipt-tear
From
To
Contact card
연락처의 아바타, 이름, 국기, 결제 수단을 보여 주는 카드. 필요하면 세부 정보도 붙이고, 누를 수 있다. 송금 시작 화면에서 수취인을 고르는 격자에 쓴다.
시각 구조
- 세로 flex 레이아웃, 가운데 정렬
- 맨 위에 아바타. 이미지가 없으면 이니셜로 대신한다
- 이름, 국기와 결제 수단, 필요하면 세부 정보
- Hover: 배경과 그림자가 부드럽게 바뀐다
구조
맨 위에 아바타나 이니셜, 그 아래 이름, 국기와 송금 수단, 필요하면 일부를 가린 계좌 정보가 온다. 격자 변형에서는 가운데 정렬한다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Grid | layout | 송금 시작 화면에서 수취인 선택 |
| Row | layout | 연락처 목록 |
API
interface ContactCardProps {
name: string;
countryCode: string;
method: string; // selecting a contact selects a contact *and* a rail
detail?: string; // masked account fragment
avatarUrl?: string; // falls back to initials — the normal case under Save-Data
selected?: boolean;
onSelect: () => void;
}
상태
| State | Visual | Token |
|---|---|---|
| Default | --ds-contactcard-bg, --ds-contactcard-border | — |
| Hover | 배경이 바뀌고 그림자가 깊어진다 | --ds-contactcard-hover-bg, --ds-contactcard-hover-shadow |
| Focus-visible | 포커스 링 | --ds-focus-ring |
| Selected | --ds-primary 테두리, --ds-primary-light 배경 | — |
hover에서는 그림자만 깊어지고 테두리는 바꾸지 않는다. 테두리가 바뀌는 건 선택됐다는 뜻이고, 두 상태는 늘 구분되어야 한다. 돈을 보낼 사람을 고르는 사용자는 누가 선택됐는지 확실히 알 수 있어야 한다.
동작
- 이미지가 로드되지 않으면 아바타 대신 이니셜을 보여 준다. Save-Data 모드에서는 이게 기본 모습이다.
- 같은 사람이라도 은행 송금으로 받을 때와 크립토로 받을 때 수수료와 도착 시간이 다르다. 그래서 카드에 결제 수단을 보여 준다. 연락처를 고르는 순간 사람과 송금 경로를 함께 고른다.
- 카드를 고르면 바로 다음 단계로 넘어간다. 제출을 기다리는 폼 필드가 아니라 선택 자체다.
콘텐츠
송금 수단은 짐작하게 두지 않고 직접 보여 준다. 같은 사람이라도 은행 송금과 모바일 머니는 수수료가 다른 별개의 선택지다.
접근성
<button>으로 렌더링하고, 화면에 보이는 내용을 모두 접근 가능한 이름에 담는다. "Maria Santos, Nigeria, bank transfer."- 국기는 장식이다. 국가는 이름에 이미 들어 있다.
- 최소 터치 영역은 48px이고, 카드는 그보다 훨씬 크다.
- 선택 상태는
aria-pressed로 전달한다. 격자가 하나만 고르는 그룹이면 라디오 시맨틱을 쓴다.
키보드
| Key | Action |
|---|---|
Tab | 카드 하나에서 한 번 멈춘다 |
Enter / Space | 선택하고 다음 단계로 넘어간다 |
토큰
--ds-contactcard-bg · --ds-contactcard-border · --ds-contactcard-hover-bg · --ds-contactcard-hover-shadow
디자인과 접근성
- 상태 배지는 색 + 점 + 텍스트를 함께 써서 색각 이상이 있는 사용자도 구분할 수 있다
- Amount input에는 보이는 라벨이 있고, 환율은 aria-describedby로 연결한다
- 거래 행은 키보드로 포커스할 수 있고, enter로 상세를 연다
- 상호작용 요소의 터치 영역은 모두 최소 48px이다
- Camera capture 셔터 버튼에는 변형마다 무엇을 하는지 알려 주는
aria-label을 단다 - QR scanner의 스캔 라인 애니메이션은
prefers-reduced-motion: reduce를 따른다 - Receipt view는 최상위에 시맨틱
article을, 키-값 쌍에dl/dt/dd를 쓴다 - Contact card는 키보드로 온전히 쓸 수 있도록
<button>요소를 쓴다
돈 컴포넌트에만 해당하는 규칙
- 금액에는 항상 통화를 붙인다. 이 페이지에서 예외는 없다.
- Sent는 completed가 아니다. 초록색은 수취인이 실제로 받은 돈에만 쓴다.
- 견적 환율에는 유효 시간을 함께 보여 준다. 지난 환율에는 취소선을 긋고, 현재 값처럼 보여 주지 않는다.
- 스캔이나 촬영에는 항상 직접 입력하는 방법을 둔다. 카메라로만 진행하게 하면 제품이 꼭 챙겨야 할 사용자를 놓친다.
- 금액은
tabular-nums를 쓴다. 자릿수가 열로 맞아서 튀는 값이 눈에 띈다. - 검증 에러가 나도 사용자가 입력한 금액은 지우지 않는다.
권장과 금지
| Do | Don't |
|---|---|
| Mark a transfer "Sent" until the recipient has it | Show green the moment the money leaves |
| Show "250.00 USD → 412,500.00 NGN" | Show "250.00 → 412,500.00" |
| Offer manual entry beside the QR scanner | Ship scan-only recipient entry |
| Keep the applied rate on the receipt forever | Re-render a receipt with today's rate |
Use inputmode="decimal" for amounts | Use type="number" with spinner arrows |
| Let the whole transaction row be one control | Add a second button inside the row |
알려진 공백
- Camera capture는 보여 주기용 화면이고, 네이티브 카메라와 연동되어 있지 않다.
- Amount input에 크립토 소수 자릿수(8자리)는 정의했지만, 크립토 전용 변형은 문서로 정리하지 않았다.
- 패턴 문서에는 오프라인 상태가 설명되어 있지만, 이 페이지의 컴포넌트 중 오프라인 상태를 따로 문서화한 것은 없다.
- 실제 구현은
.snc-*클래스로 되어 있고, 그중 상당수는 대응하는--ds-토큰이 없다.