From 30c45be73732f445c565a5d8b43dc8e0a37d6065 Mon Sep 17 00:00:00 2001 From: Justine Ifedozie Date: Thu, 30 Jul 2026 05:45:38 +0100 Subject: [PATCH] feat: add reusable status system (#182) --- docs/status-system.md | 133 ++++++++++++++ src/components/status/StatusBadge.test.tsx | 41 +++++ src/components/status/StatusBadge.tsx | 67 +++++++ src/components/status/index.ts | 2 + .../components/WhitelistManager.tsx | 19 +- .../diagnostics/components/StatusCard.tsx | 18 +- .../components/IssuanceRequestsTable.tsx | 16 +- src/lib/status/domainMappers.test.ts | 155 ++++++++++++++++ src/lib/status/domainMappers.ts | 171 ++++++++++++++++++ src/lib/status/index.ts | 32 ++++ src/lib/status/severity.test.ts | 68 +++++++ src/lib/status/severity.ts | 60 ++++++ src/lib/status/toneStyles.ts | 59 ++++++ src/lib/status/types.ts | 52 ++++++ tailwind.config.js | 1 + 15 files changed, 858 insertions(+), 36 deletions(-) create mode 100644 docs/status-system.md create mode 100644 src/components/status/StatusBadge.test.tsx create mode 100644 src/components/status/StatusBadge.tsx create mode 100644 src/components/status/index.ts create mode 100644 src/lib/status/domainMappers.test.ts create mode 100644 src/lib/status/domainMappers.ts create mode 100644 src/lib/status/index.ts create mode 100644 src/lib/status/severity.test.ts create mode 100644 src/lib/status/severity.ts create mode 100644 src/lib/status/toneStyles.ts create mode 100644 src/lib/status/types.ts diff --git a/docs/status-system.md b/docs/status-system.md new file mode 100644 index 0000000..5fac86a --- /dev/null +++ b/docs/status-system.md @@ -0,0 +1,133 @@ +# Shared Status System + +`src/lib/status/` and `src/components/status/` provide one consistent way to +label, colour, and prioritise a status — used across the compliance, asset, +transaction, wallet, and diagnostics screens instead of each one defining +its own colour map. + +## The problem this solves + +Before this, several components each hardcoded their own +`Record` of Tailwind classes for the same visual idea: + +- `ComplianceBadge.tsx`, `AssetLifecycleBadge.tsx` — bordered badges +- `IssuanceRequestsTable.tsx` — a pill badge with its own status colours +- `WhitelistManager.tsx` — an inline whitelisted/revoked badge +- `StatusCard.tsx` (Diagnostics) — a card with its own `statusColors` map + +Nothing kept these in sync. "Critical" could be `red` in one place and +`rose` in another purely by accident, and a new screen had no obvious +existing pattern to copy. + +## How it's structured + +``` +src/lib/status/ + types.ts StatusTone, StatusSeverity, StatusInfo + severity.ts tone <-> severity mapping, sorting/threshold helpers + toneStyles.ts Tailwind classes per tone, per variant (pill/outline/card) + domainMappers.ts one function per domain: domain status -> StatusInfo + index.ts barrel + +src/components/status/ + StatusBadge.tsx renders a StatusInfo as a badge (pill or outline) + index.ts barrel +``` + +**`StatusTone`** (`'success' | 'neutral' | 'caution' | 'critical' | 'unknown'`) +is the visual/semantic category. **`StatusSeverity`** +(`'none' | 'low' | 'medium' | 'high' | 'critical'`) is how urgently a status +needs attention — useful for sorting a table by "what needs review first" +across mixed status types. Every tone has a default severity +(`TONE_SEVERITY` in `severity.ts`). + +A **domain mapper** converts an existing domain status value into a +`StatusInfo`. No domain's own status type changes — `ComplianceState`, +`AssetLifecycleState`, `TransactionStatus`, `WhitelistEntryStatus`, etc. all +still live where they always did. The mapper is purely a translation into +the shared display layer: + +```ts +import { statusForComplianceState } from '@/lib/status'; + +statusForComplianceState('restricted'); +// => { label: 'Restricted', tone: 'critical', severity: 'critical', detail: '...' } +``` + +Covered domains and their mapper functions: + +| Domain | Source type | Mapper | +|---|---|---| +| Compliance | `ComplianceState` (`src/lib/aegis/types.ts`) | `statusForComplianceState` | +| Compliance review severity | `ReviewSeverity` (`src/lib/complianceReview.ts`) | `statusForReviewSeverity` | +| Asset — transfer eligibility | `TransferEligibilityState` (`src/lib/aegis/types.ts`) | `statusForTransferEligibility` | +| Asset — lifecycle | `AssetLifecycleState` (`src/lib/assetLifecycle.ts`) | `statusForAssetLifecycle` | +| Asset — issuance request | `IssuanceRequest['status']` (`src/fixtures/issuer.ts`) | `statusForIssuanceRequest` | +| Transaction | `TransactionStatus` (`src/features/transactions/types.ts`) | `statusForTransaction` | +| Wallet — KYC whitelist | `WhitelistEntryStatus` (`src/lib/whitelist.ts`) | `statusForWhitelistEntry` | +| Diagnostics | `DiagnosticsCardStatus` (`'ok' \| 'warning' \| 'error' \| 'unknown'`) | `statusForDiagnostics` | + +## Rendering a status + +```tsx +import { StatusBadge } from '@/components/status'; +import { statusForTransaction } from '@/lib/status'; + + +``` + +`variant` is `'outline'` (bordered rectangle, default) or `'pill'` +(rounded-full). Each tone gets a matching icon automatically (check circle +for success, triangle for caution, X for critical, question mark for +unknown, minus for neutral) — pass `showIcon={false}` to omit it. + +The Diagnostics `StatusCard` component has its own title/value card layout +that predates `StatusBadge`, so rather than force it through the badge +component it consumes the tone class tokens directly: + +```ts +import { toneClassName } from '@/lib/status/toneStyles'; +toneClassName(tone, 'card'); +``` + +## Screens currently using the shared system + +- `src/features/diagnostics/components/StatusCard.tsx` +- `src/features/issuer/components/IssuanceRequestsTable.tsx` +- `src/features/compliance/components/WhitelistManager.tsx` + +`ComplianceBadge.tsx`, `TransferEligibilityBadge.tsx`, and +`AssetLifecycleBadge.tsx` were left as-is for this change (they already had +a reasonably consistent internal pattern) but are natural next candidates +to migrate onto `StatusBadge` — their domain mappers +(`statusForComplianceState`, `statusForTransferEligibility`, +`statusForAssetLifecycle`) already exist and are ready to use. + +## Adding a new domain + +1. Add a `statusForYourDomain(state: YourDomainState): StatusInfo` function + to `domainMappers.ts`, choosing the tone that matches its real-world + urgency (see the table above for precedent). +2. Export it from `src/lib/status/index.ts`. +3. Add test cases to `domainMappers.test.ts` covering every value of your + domain's status enum, and a couple of semantic assertions (e.g. "a + rejected state must never map to a success tone"). +4. Use `` wherever the + status needs to render. + +## Tailwind content scanning + +`toneStyles.ts` lives in `src/lib/status/`, which contains literal Tailwind +class strings (not JSX). `tailwind.config.js`'s `content` array had to be +updated to include `./src/lib/**/*.{js,ts,jsx,tsx,mdx}` — without this, the +classes in `toneStyles.ts` would be silently purged from the production +build (see the note in `CONTRIBUTING.md` about adding new component +directories to the Tailwind content scan). + +## Related + +- `src/lib/status/` — implementation +- `src/lib/status/domainMappers.test.ts`, `src/lib/status/severity.test.ts` — tests +- `src/components/status/StatusBadge.test.tsx` — component smoke tests +- `docs/asset-lifecycle-status.md` — the pre-existing `LifecycleTone` pattern + this system generalises diff --git a/src/components/status/StatusBadge.test.tsx b/src/components/status/StatusBadge.test.tsx new file mode 100644 index 0000000..5811f0b --- /dev/null +++ b/src/components/status/StatusBadge.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import StatusBadge from './StatusBadge'; +import { statusForComplianceState, statusForTransaction } from '@/lib/status'; + +describe('StatusBadge', () => { + it('renders the label from the given StatusInfo', () => { + const { getByText } = render(); + expect(getByText('Compliant')).toBeInTheDocument(); + }); + + it('renders the detail as a title attribute for a tooltip', () => { + const status = statusForComplianceState('restricted'); + const { getByText } = render(); + expect(getByText('Restricted').closest('span')).toHaveAttribute('title', status.detail); + }); + + it('applies pill shape classes when variant="pill"', () => { + const { getByText } = render( + , + ); + expect(getByText('Success').closest('span')?.className).toContain('rounded-full'); + }); + + it('applies outline shape classes by default', () => { + const { getByText } = render(); + expect(getByText('Failed').closest('span')?.className).toContain('border'); + }); + + it('hides the icon when showIcon is false', () => { + const { container } = render( + , + ); + expect(container.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('shows an icon by default', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/src/components/status/StatusBadge.tsx b/src/components/status/StatusBadge.tsx new file mode 100644 index 0000000..d722336 --- /dev/null +++ b/src/components/status/StatusBadge.tsx @@ -0,0 +1,67 @@ +import { + CheckCircle2, + AlertTriangle, + XCircle, + HelpCircle, + MinusCircle, + type LucideIcon, +} from 'lucide-react'; +import type { StatusInfo, StatusTone } from '@/lib/status/types'; +import { toneClassName } from '@/lib/status/toneStyles'; + +const TONE_ICON: Record = { + success: CheckCircle2, + neutral: MinusCircle, + caution: AlertTriangle, + critical: XCircle, + unknown: HelpCircle, +}; + +/** + * Badge-shaped variants only. The 'card' tone tokens in toneStyles.ts are + * for larger summary tiles (e.g. the Diagnostics StatusCard) which have + * their own title/value layout and consume `toneClassName(tone, 'card')` + * directly rather than rendering through this component. + */ +export type StatusBadgeShape = 'pill' | 'outline'; + +export interface StatusBadgeProps { + /** A `StatusInfo` from one of the domain mappers in src/lib/status. */ + status: StatusInfo; + /** Visual style. 'pill' (rounded-full) or 'outline' (bordered rectangle, default). */ + variant?: StatusBadgeShape; + /** Show the tone icon before the label. Default true. */ + showIcon?: boolean; + /** Icon size in pixels. Default 12. */ + iconSize?: number; + className?: string; +} + +/** + * Renders a `StatusInfo` consistently regardless of which domain produced + * it. This is the single place that decides what "critical" looks like — + * individual features should not define their own status color maps. + * + * @see docs/status-system.md + */ +export default function StatusBadge({ + status, + variant = 'outline', + showIcon = true, + iconSize = 12, + className = '', +}: StatusBadgeProps) { + const Icon = TONE_ICON[status.tone]; + const toneClasses = toneClassName(status.tone, variant); + const shapeClasses = variant === 'pill' ? 'rounded-full px-2.5 py-0.5' : 'rounded border px-2 py-1'; + + return ( + + {showIcon && + ); +} diff --git a/src/components/status/index.ts b/src/components/status/index.ts new file mode 100644 index 0000000..413a436 --- /dev/null +++ b/src/components/status/index.ts @@ -0,0 +1,2 @@ +export { default as StatusBadge } from './StatusBadge'; +export type { StatusBadgeProps, StatusBadgeShape } from './StatusBadge'; diff --git a/src/features/compliance/components/WhitelistManager.tsx b/src/features/compliance/components/WhitelistManager.tsx index 38aadbf..d6aae9f 100644 --- a/src/features/compliance/components/WhitelistManager.tsx +++ b/src/features/compliance/components/WhitelistManager.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react'; -import { ShieldCheck, ShieldX, Plus, AlertTriangle } from 'lucide-react'; +import { ShieldCheck, Plus, AlertTriangle } from 'lucide-react'; import { useAegis } from '@/hooks/useAegis'; import { useWallet } from '@/hooks/useWallet'; import TableSearch from '@/components/table/TableSearch'; @@ -13,6 +13,8 @@ import { } from '@/lib/whitelist'; import { useFormErrors, FormFieldError } from '@/features/forms/validation'; import { formatTimestamp, truncateAddress } from '@/utils/formatting'; +import { StatusBadge } from '@/components/status'; +import { statusForWhitelistEntry } from '@/lib/status'; type WhitelistFormField = 'address'; @@ -214,20 +216,7 @@ export default function WhitelistManager() { {truncateAddress(entry.address)} - - {entry.status === 'whitelisted' ? ( - + {formatTimestamp(entry.updatedAt)} {entry.note ?? '\u2014'} diff --git a/src/features/diagnostics/components/StatusCard.tsx b/src/features/diagnostics/components/StatusCard.tsx index 3e09489..6597d40 100644 --- a/src/features/diagnostics/components/StatusCard.tsx +++ b/src/features/diagnostics/components/StatusCard.tsx @@ -1,21 +1,23 @@ import React from 'react'; +import { statusForDiagnostics, type DiagnosticsCardStatus } from '@/lib/status/domainMappers'; +import { toneClassName } from '@/lib/status/toneStyles'; interface StatusCardProps { title: string; value: string; - status: 'ok' | 'warning' | 'error' | 'unknown'; + status: DiagnosticsCardStatus; } +/** + * Uses the shared status system (src/lib/status) for colour, so "warning" + * or "error" here always match the same tone used on the compliance, + * asset, transaction, and wallet screens. See docs/status-system.md. + */ export default function StatusCard({ title, value, status }: StatusCardProps) { - const statusColors = { - ok: 'bg-green-100 text-green-800 border-green-200', - warning: 'bg-yellow-100 text-yellow-800 border-yellow-200', - error: 'bg-red-100 text-red-800 border-red-200', - unknown: 'bg-slate-100 text-slate-800 border-slate-200', - }; + const { tone } = statusForDiagnostics(status); return ( -
+

{title}

{value}

diff --git a/src/features/issuer/components/IssuanceRequestsTable.tsx b/src/features/issuer/components/IssuanceRequestsTable.tsx index b8a1a44..701d37e 100644 --- a/src/features/issuer/components/IssuanceRequestsTable.tsx +++ b/src/features/issuer/components/IssuanceRequestsTable.tsx @@ -9,14 +9,8 @@ import { import { useTableFilters } from '@/hooks/useTableFilters'; import type { IssuanceRequest } from '@/fixtures/issuer'; import { EmptyState } from '@/components/states'; - -const STATUS_STYLES: Record = { - draft: 'bg-slate-100 text-slate-600', - pending: 'bg-amber-100 text-amber-800', - approved: 'bg-sky-100 text-sky-800', - minted: 'bg-emerald-100 text-emerald-800', - rejected: 'bg-rose-100 text-rose-800', -}; +import { StatusBadge } from '@/components/status'; +import { statusForIssuanceRequest } from '@/lib/status'; function formatAmount(value: number): string { return new Intl.NumberFormat('en-US', { @@ -201,11 +195,7 @@ export default function IssuanceRequestsTable({ - - {req.status} - + {new Date(req.requestedAt).toLocaleDateString('en-US', { diff --git a/src/lib/status/domainMappers.test.ts b/src/lib/status/domainMappers.test.ts new file mode 100644 index 0000000..4e2f0f2 --- /dev/null +++ b/src/lib/status/domainMappers.test.ts @@ -0,0 +1,155 @@ +/** + * Tests for src/lib/status/domainMappers.ts (Issue #182). + * + * Confirms every value of every domain's status enum maps to a valid + * StatusInfo, and spot-checks a few semantically important cases (e.g. a + * rejected/restricted/failed status must never map to a 'success' tone). + */ + +import { + statusForComplianceState, + statusForReviewSeverity, + statusForTransferEligibility, + statusForAssetLifecycle, + statusForIssuanceRequest, + statusForTransaction, + statusForWhitelistEntry, + statusForDiagnostics, +} from './domainMappers'; +import { SEVERITY_ORDER } from './severity'; +import type { StatusInfo } from './types'; + +const VALID_TONES = ['success', 'neutral', 'caution', 'critical', 'unknown']; + +function expectValidStatusInfo(result: StatusInfo) { + expect(typeof result.label).toBe('string'); + expect(result.label.length).toBeGreaterThan(0); + expect(VALID_TONES).toContain(result.tone); + expect(SEVERITY_ORDER).toContain(result.severity); +} + +describe('statusForComplianceState', () => { + it.each(['compliant', 'restricted', 'pending_review'] as const)( + 'produces a valid StatusInfo for %s', + (state) => { + expectValidStatusInfo(statusForComplianceState(state)); + }, + ); + + it('never maps restricted to a success tone', () => { + expect(statusForComplianceState('restricted').tone).not.toBe('success'); + expect(statusForComplianceState('restricted').tone).toBe('critical'); + }); + + it('maps compliant to success', () => { + expect(statusForComplianceState('compliant').tone).toBe('success'); + }); +}); + +describe('statusForReviewSeverity', () => { + it.each(['low', 'medium', 'high', 'critical'] as const)( + 'produces a valid StatusInfo for %s and preserves the severity value', + (severity) => { + const result = statusForReviewSeverity(severity); + expectValidStatusInfo(result); + expect(result.severity).toBe(severity); + }, + ); + + it('maps critical severity to the critical tone', () => { + expect(statusForReviewSeverity('critical').tone).toBe('critical'); + }); +}); + +describe('statusForTransferEligibility', () => { + it.each(['eligible', 'ineligible', 'unknown'] as const)( + 'produces a valid StatusInfo for %s', + (state) => { + expectValidStatusInfo(statusForTransferEligibility(state)); + }, + ); + + it('never maps ineligible to a success tone', () => { + expect(statusForTransferEligibility('ineligible').tone).not.toBe('success'); + }); +}); + +describe('statusForAssetLifecycle', () => { + it.each(['active', 'paused', 'matured', 'redeemed', 'defaulted'] as const)( + 'produces a valid StatusInfo for %s', + (state) => { + expectValidStatusInfo(statusForAssetLifecycle(state)); + }, + ); + + it('maps defaulted to critical and active to success, matching LIFECYCLE_STATE_INFO tones', () => { + expect(statusForAssetLifecycle('defaulted').tone).toBe('critical'); + expect(statusForAssetLifecycle('active').tone).toBe('success'); + }); + + it('carries over the existing lifecycle label and detail text verbatim', () => { + const result = statusForAssetLifecycle('paused'); + expect(result.label).toBe('Paused'); + expect(result.detail).toContain('paused'); + }); +}); + +describe('statusForIssuanceRequest', () => { + it.each(['draft', 'pending', 'approved', 'minted', 'rejected'] as const)( + 'produces a valid StatusInfo for %s', + (status) => { + expectValidStatusInfo(statusForIssuanceRequest(status)); + }, + ); + + it('never maps rejected to a success tone', () => { + expect(statusForIssuanceRequest('rejected').tone).toBe('critical'); + }); + + it('maps both approved and minted to success', () => { + expect(statusForIssuanceRequest('approved').tone).toBe('success'); + expect(statusForIssuanceRequest('minted').tone).toBe('success'); + }); +}); + +describe('statusForTransaction', () => { + it.each(['success', 'pending', 'failed', 'unknown'] as const)( + 'produces a valid StatusInfo for %s', + (status) => { + expectValidStatusInfo(statusForTransaction(status)); + }, + ); + + it('maps failed to critical and success to success', () => { + expect(statusForTransaction('failed').tone).toBe('critical'); + expect(statusForTransaction('success').tone).toBe('success'); + }); +}); + +describe('statusForWhitelistEntry', () => { + it.each(['whitelisted', 'revoked'] as const)( + 'produces a valid StatusInfo for %s', + (status) => { + expectValidStatusInfo(statusForWhitelistEntry(status)); + }, + ); + + it('maps whitelisted to success and revoked to a non-critical tone', () => { + expect(statusForWhitelistEntry('whitelisted').tone).toBe('success'); + expect(statusForWhitelistEntry('revoked').tone).not.toBe('critical'); + }); +}); + +describe('statusForDiagnostics', () => { + it.each(['ok', 'warning', 'error', 'unknown'] as const)( + 'produces a valid StatusInfo for %s', + (status) => { + expectValidStatusInfo(statusForDiagnostics(status)); + }, + ); + + it('maps error to critical and ok to success', () => { + expect(statusForDiagnostics('error').tone).toBe('critical'); + expect(statusForDiagnostics('ok').tone).toBe('success'); + }); +}); diff --git a/src/lib/status/domainMappers.ts b/src/lib/status/domainMappers.ts new file mode 100644 index 0000000..929d63b --- /dev/null +++ b/src/lib/status/domainMappers.ts @@ -0,0 +1,171 @@ +/** + * src/lib/status/domainMappers.ts + * + * Domain mappers for the shared status system. (Issue #182) + * + * Each function below takes a status value from an existing domain module + * and returns a `StatusInfo` — the same shape, rendered the same way, + * regardless of which domain it came from. No domain's own status type + * changes; this only adds a translation into the shared display layer. + * + * Covers every domain named in the issue: compliance, asset (lifecycle + + * issuance + transfer eligibility), transaction, wallet (whitelist), and + * diagnostics. + */ + +import type { ComplianceState, TransferEligibilityState } from '@/lib/aegis/types'; +import type { ReviewSeverity } from '@/lib/complianceReview'; +import { AssetLifecycleState, LIFECYCLE_STATE_INFO } from '@/lib/assetLifecycle'; +import type { IssuanceRequest } from '@/fixtures/issuer'; +import type { TransactionStatus } from '@/features/transactions/types'; +import type { WhitelistEntryStatus } from '@/lib/whitelist'; +import type { StatusInfo, StatusTone } from './types'; +import { severityForTone, severityForReviewSeverity } from './severity'; + +function info(label: string, tone: StatusTone, detail?: string): StatusInfo { + return { label, tone, severity: severityForTone(tone), detail }; +} + +// --------------------------------------------------------------------------- +// Compliance (src/lib/aegis/types.ts — ComplianceState) +// --------------------------------------------------------------------------- + +export function statusForComplianceState(state: ComplianceState): StatusInfo { + switch (state) { + case 'compliant': + return info('Compliant', 'success', 'Investor KYC and accreditation checks are current.'); + case 'restricted': + return info('Restricted', 'critical', 'This asset class is currently restricted for this investor.'); + case 'pending_review': + return info('Pending Review', 'caution', 'The compliance registry has not returned a result yet.'); + default: + return info(state, 'unknown'); + } +} + +/** + * Compliance review queue severity (src/lib/complianceReview.ts — + * ReviewSeverity). This is already a severity, not a status label, so the + * mapping goes the other direction: severity -> tone -> a generic label. + */ +export function statusForReviewSeverity(severity: ReviewSeverity): StatusInfo { + const TONE_BY_SEVERITY: Record = { + low: 'neutral', + medium: 'unknown', + high: 'caution', + critical: 'critical', + }; + const tone = TONE_BY_SEVERITY[severity]; + const label = severity.charAt(0).toUpperCase() + severity.slice(1); + return { label, tone, severity: severityForReviewSeverity(severity) }; +} + +// --------------------------------------------------------------------------- +// Asset — transfer eligibility (src/lib/aegis/types.ts) +// --------------------------------------------------------------------------- + +export function statusForTransferEligibility(state: TransferEligibilityState): StatusInfo { + switch (state) { + case 'eligible': + return info('Transfer Eligible', 'success'); + case 'ineligible': + return info('Transfer Restricted', 'critical'); + case 'unknown': + return info('Eligibility Unknown', 'unknown'); + default: + return info(state, 'unknown'); + } +} + +// --------------------------------------------------------------------------- +// Asset — lifecycle (src/lib/assetLifecycle.ts) +// --------------------------------------------------------------------------- + +const LIFECYCLE_TONE: Record = { + positive: 'success', + neutral: 'neutral', + caution: 'caution', + negative: 'critical', +}; + +export function statusForAssetLifecycle(state: AssetLifecycleState): StatusInfo { + const lifecycleInfo = LIFECYCLE_STATE_INFO[state]; + const tone = LIFECYCLE_TONE[lifecycleInfo.tone]; + return { label: lifecycleInfo.label, tone, severity: severityForTone(tone), detail: lifecycleInfo.detail }; +} + +// --------------------------------------------------------------------------- +// Asset — issuance request (src/fixtures/issuer.ts) +// --------------------------------------------------------------------------- + +export function statusForIssuanceRequest(status: IssuanceRequest['status']): StatusInfo { + switch (status) { + case 'draft': + return info('Draft', 'neutral', 'Not yet submitted for compliance review.'); + case 'pending': + return info('Pending', 'caution', 'Awaiting compliance review.'); + case 'approved': + return info('Approved', 'success', 'Approved for minting.'); + case 'minted': + return info('Minted', 'success', 'Supply has been issued on-chain.'); + case 'rejected': + return info('Rejected', 'critical', 'The issuance request was rejected.'); + default: + return info(status, 'unknown'); + } +} + +// --------------------------------------------------------------------------- +// Transaction (src/features/transactions/types.ts — TransactionStatus) +// --------------------------------------------------------------------------- + +export function statusForTransaction(status: TransactionStatus): StatusInfo { + switch (status) { + case 'success': + return info('Success', 'success'); + case 'pending': + return info('Pending', 'caution'); + case 'failed': + return info('Failed', 'critical'); + case 'unknown': + return info('Unknown', 'unknown'); + default: + return info(status, 'unknown'); + } +} + +// --------------------------------------------------------------------------- +// Wallet — KYC whitelist (src/lib/whitelist.ts — WhitelistEntryStatus) +// --------------------------------------------------------------------------- + +export function statusForWhitelistEntry(status: WhitelistEntryStatus): StatusInfo { + switch (status) { + case 'whitelisted': + return info('Whitelisted', 'success'); + case 'revoked': + return info('Revoked', 'neutral'); + default: + return info(status, 'unknown'); + } +} + +// --------------------------------------------------------------------------- +// Diagnostics (src/features/diagnostics/components/StatusCard.tsx) +// --------------------------------------------------------------------------- + +export type DiagnosticsCardStatus = 'ok' | 'warning' | 'error' | 'unknown'; + +export function statusForDiagnostics(status: DiagnosticsCardStatus): StatusInfo { + switch (status) { + case 'ok': + return info('OK', 'success'); + case 'warning': + return info('Warning', 'caution'); + case 'error': + return info('Error', 'critical'); + case 'unknown': + return info('Unknown', 'unknown'); + default: + return info(status, 'unknown'); + } +} diff --git a/src/lib/status/index.ts b/src/lib/status/index.ts new file mode 100644 index 0000000..35985d8 --- /dev/null +++ b/src/lib/status/index.ts @@ -0,0 +1,32 @@ +/** + * src/lib/status/index.ts + * + * Barrel for the shared status system. See docs/status-system.md. + */ + +export type { StatusTone, StatusSeverity, StatusInfo } from './types'; + +export { + SEVERITY_ORDER, + TONE_SEVERITY, + severityForTone, + severityWeight, + compareSeverity, + isAtLeastSeverity, + severityForReviewSeverity, +} from './severity'; + +export type { StatusBadgeVariant } from './toneStyles'; +export { TONE_PILL_STYLES, TONE_OUTLINE_STYLES, TONE_CARD_STYLES, toneClassName } from './toneStyles'; + +export { + statusForComplianceState, + statusForReviewSeverity, + statusForTransferEligibility, + statusForAssetLifecycle, + statusForIssuanceRequest, + statusForTransaction, + statusForWhitelistEntry, + statusForDiagnostics, +} from './domainMappers'; +export type { DiagnosticsCardStatus } from './domainMappers'; diff --git a/src/lib/status/severity.test.ts b/src/lib/status/severity.test.ts new file mode 100644 index 0000000..b52ed3d --- /dev/null +++ b/src/lib/status/severity.test.ts @@ -0,0 +1,68 @@ +import { + SEVERITY_ORDER, + TONE_SEVERITY, + severityForTone, + severityWeight, + compareSeverity, + isAtLeastSeverity, + severityForReviewSeverity, +} from './severity'; +import type { StatusTone } from './types'; +import type { ReviewSeverity } from '@/lib/complianceReview'; + +describe('severityForTone / TONE_SEVERITY', () => { + it('maps every tone to a severity', () => { + const tones: StatusTone[] = ['success', 'neutral', 'caution', 'critical', 'unknown']; + for (const tone of tones) { + expect(SEVERITY_ORDER).toContain(severityForTone(tone)); + expect(TONE_SEVERITY[tone]).toBe(severityForTone(tone)); + } + }); + + it('maps success to the lowest severity and critical to the highest', () => { + expect(severityForTone('success')).toBe('none'); + expect(severityForTone('critical')).toBe('critical'); + }); +}); + +describe('severityWeight / compareSeverity', () => { + it('orders severities from none to critical', () => { + expect(severityWeight('none')).toBeLessThan(severityWeight('low')); + expect(severityWeight('low')).toBeLessThan(severityWeight('medium')); + expect(severityWeight('medium')).toBeLessThan(severityWeight('high')); + expect(severityWeight('high')).toBeLessThan(severityWeight('critical')); + }); + + it('compareSeverity sorts ascending by urgency', () => { + const shuffled = ['critical', 'none', 'high', 'low', 'medium'] as const; + const sorted = [...shuffled].sort(compareSeverity); + expect(sorted).toEqual(['none', 'low', 'medium', 'high', 'critical']); + }); + + it('can sort a mixed list of statuses by severity, most urgent first', () => { + const severities = ['low', 'critical', 'none', 'high'] as const; + const sorted = [...severities].sort((a, b) => compareSeverity(b, a)); + expect(sorted).toEqual(['critical', 'high', 'low', 'none']); + }); +}); + +describe('isAtLeastSeverity', () => { + it('returns true when severity meets or exceeds the threshold', () => { + expect(isAtLeastSeverity('critical', 'high')).toBe(true); + expect(isAtLeastSeverity('high', 'high')).toBe(true); + }); + + it('returns false when severity is below the threshold', () => { + expect(isAtLeastSeverity('low', 'high')).toBe(false); + expect(isAtLeastSeverity('none', 'low')).toBe(false); + }); +}); + +describe('severityForReviewSeverity', () => { + it('losslessly maps every ReviewSeverity value into StatusSeverity', () => { + const reviewSeverities: ReviewSeverity[] = ['low', 'medium', 'high', 'critical']; + for (const severity of reviewSeverities) { + expect(severityForReviewSeverity(severity)).toBe(severity); + } + }); +}); diff --git a/src/lib/status/severity.ts b/src/lib/status/severity.ts new file mode 100644 index 0000000..c4a88e3 --- /dev/null +++ b/src/lib/status/severity.ts @@ -0,0 +1,60 @@ +/** + * src/lib/status/severity.ts + * + * Severity mapping for the shared status system. (Issue #182) + * + * Provides the canonical tone -> severity mapping, ordering for sorting a + * mixed list of statuses by urgency, and a bridge to the pre-existing + * `ReviewSeverity` type used by the compliance review queue so the two + * severity concepts stay consistent rather than drifting apart. + */ + +import type { ReviewSeverity } from '@/lib/complianceReview'; +import type { StatusSeverity, StatusTone } from './types'; + +/** Ascending urgency order. Index doubles as a sortable weight. */ +export const SEVERITY_ORDER: StatusSeverity[] = ['none', 'low', 'medium', 'high', 'critical']; + +/** Canonical tone -> severity mapping used by every domain mapper. */ +export const TONE_SEVERITY: Record = { + success: 'none', + neutral: 'low', + unknown: 'medium', + caution: 'high', + critical: 'critical', +}; + +/** Look up the default severity for a tone. */ +export function severityForTone(tone: StatusTone): StatusSeverity { + return TONE_SEVERITY[tone]; +} + +/** Numeric weight for a severity, for sorting (higher = more urgent). */ +export function severityWeight(severity: StatusSeverity): number { + return SEVERITY_ORDER.indexOf(severity); +} + +/** + * Compare two severities for sorting, most urgent first. + * Usable directly as an Array.prototype.sort comparator over StatusInfo: + * `list.sort((a, b) => compareSeverity(b.severity, a.severity))` for + * least-urgent-first, or flip operands for most-urgent-first. + */ +export function compareSeverity(a: StatusSeverity, b: StatusSeverity): number { + return severityWeight(a) - severityWeight(b); +} + +/** Whether `severity` meets or exceeds `threshold` in urgency. */ +export function isAtLeastSeverity(severity: StatusSeverity, threshold: StatusSeverity): boolean { + return severityWeight(severity) >= severityWeight(threshold); +} + +/** + * Bridge to the compliance review queue's own `ReviewSeverity` type + * (src/lib/complianceReview.ts), which predates this module and remains the + * source of truth for review-queue severity. `ReviewSeverity` has no 'none' + * value, so this is a lossless one-way mapping into `StatusSeverity`. + */ +export function severityForReviewSeverity(severity: ReviewSeverity): StatusSeverity { + return severity; +} diff --git a/src/lib/status/toneStyles.ts b/src/lib/status/toneStyles.ts new file mode 100644 index 0000000..91d8f87 --- /dev/null +++ b/src/lib/status/toneStyles.ts @@ -0,0 +1,59 @@ +/** + * src/lib/status/toneStyles.ts + * + * Single source of truth for status colours. (Issue #182) + * + * Before this module, each status-rendering component (ComplianceBadge, + * AssetLifecycleBadge, StatusCard, the Issuer Console status pill, the + * whitelist badge, ...) defined its own `Record` of + * Tailwind classes. That meant "critical" could be red in one place and + * rose in another purely by accident. Every tone now has exactly one style + * per variant, defined here. + * + * Variants: + * - `pill` — rounded-full badge (matches the Issuer Console / whitelist + * style already in use) + * - `outline` — bordered rectangular badge (matches the existing + * ComplianceBadge / AssetLifecycleBadge style) + * - `card` — larger bordered block (matches the Diagnostics StatusCard + * style) + */ + +import type { StatusTone } from './types'; + +export type StatusBadgeVariant = 'pill' | 'outline' | 'card'; + +export const TONE_PILL_STYLES: Record = { + success: 'bg-emerald-50 text-emerald-700', + neutral: 'bg-slate-100 text-slate-600', + caution: 'bg-amber-100 text-amber-800', + critical: 'bg-rose-100 text-rose-800', + unknown: 'bg-slate-100 text-slate-500', +}; + +export const TONE_OUTLINE_STYLES: Record = { + success: 'bg-emerald-50 text-emerald-700 border-emerald-200', + neutral: 'bg-slate-50 text-slate-700 border-slate-200', + caution: 'bg-amber-50 text-amber-700 border-amber-200', + critical: 'bg-red-50 text-red-700 border-red-200', + unknown: 'bg-slate-50 text-slate-500 border-slate-200', +}; + +export const TONE_CARD_STYLES: Record = { + success: 'bg-green-100 text-green-800 border-green-200', + neutral: 'bg-slate-100 text-slate-800 border-slate-200', + caution: 'bg-yellow-100 text-yellow-800 border-yellow-200', + critical: 'bg-red-100 text-red-800 border-red-200', + unknown: 'bg-slate-100 text-slate-600 border-slate-200', +}; + +export const VARIANT_STYLES: Record> = { + pill: TONE_PILL_STYLES, + outline: TONE_OUTLINE_STYLES, + card: TONE_CARD_STYLES, +}; + +/** Look up the class string for a tone + variant. */ +export function toneClassName(tone: StatusTone, variant: StatusBadgeVariant = 'outline'): string { + return VARIANT_STYLES[variant][tone]; +} diff --git a/src/lib/status/types.ts b/src/lib/status/types.ts new file mode 100644 index 0000000..51fca3e --- /dev/null +++ b/src/lib/status/types.ts @@ -0,0 +1,52 @@ +/** + * src/lib/status/types.ts + * + * Shared status system — core types. (Issue #182) + * + * Several domains in this codebase (compliance, asset lifecycle, transfer + * eligibility, asset issuance, transactions, wallet whitelist, diagnostics) + * each define their own status enum, which is correct — they are genuinely + * different state machines. What they should NOT each define independently + * is how a status is *labelled, coloured, and prioritised* for display. + * + * This module is that shared layer. A domain mapper (see domainMappers.ts) + * converts a domain-specific status into a `StatusInfo`; UI code then renders + * `StatusInfo` the same way everywhere via `StatusBadge` + * (src/components/status/StatusBadge.tsx). + * + * This module has no React and no domain imports, so it can be unit-tested + * in isolation and reused by any future surface. + */ + +/** + * Visual/semantic category a status falls into. Every domain status maps to + * exactly one tone. Tones are intentionally domain-agnostic — "critical" + * means the same visual treatment whether it comes from a rejected KYC + * check or a failed transaction. + */ +export type StatusTone = 'success' | 'neutral' | 'caution' | 'critical' | 'unknown'; + +/** + * How urgently a status should draw attention, independent of tone's visual + * styling. Useful for sorting/filtering a table by "what needs attention + * first" across mixed status types. + * + * Deliberately a superset of `ReviewSeverity` (src/lib/complianceReview.ts) + * — 'none' is added for statuses that need no attention at all (e.g. a + * successful transaction). Compliance's own `ReviewSeverity` type is + * unchanged and continues to be the source of truth for review-queue + * severity; `severityForReviewSeverity` in severity.ts bridges the two. + */ +export type StatusSeverity = 'none' | 'low' | 'medium' | 'high' | 'critical'; + +/** The normalised, renderable shape every domain mapper produces. */ +export interface StatusInfo { + /** Short label shown in the badge, e.g. "Compliant", "Whitelisted". */ + label: string; + /** Visual/semantic category — drives colour via toneStyles.ts. */ + tone: StatusTone; + /** How urgently this status needs attention. */ + severity: StatusSeverity; + /** Optional longer explanation, typically shown as a tooltip. */ + detail?: string; +} diff --git a/tailwind.config.js b/tailwind.config.js index dadc1fa..cd574fb 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,6 +5,7 @@ module.exports = { "./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/features/**/*.{js,ts,jsx,tsx,mdx}", "./src/hooks/**/*.{js,ts,jsx,tsx,mdx}", + "./src/lib/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: {