Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/status-system.md
Original file line number Diff line number Diff line change
@@ -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<SomeState, string>` 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';

<StatusBadge status={statusForTransaction(tx.status)} variant="pill" />
```

`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 `<StatusBadge status={statusForYourDomain(value)} />` 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
41 changes: 41 additions & 0 deletions src/components/status/StatusBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StatusBadge status={statusForComplianceState('compliant')} />);
expect(getByText('Compliant')).toBeInTheDocument();
});

it('renders the detail as a title attribute for a tooltip', () => {
const status = statusForComplianceState('restricted');
const { getByText } = render(<StatusBadge status={status} />);
expect(getByText('Restricted').closest('span')).toHaveAttribute('title', status.detail);
});

it('applies pill shape classes when variant="pill"', () => {
const { getByText } = render(
<StatusBadge status={statusForTransaction('success')} variant="pill" />,
);
expect(getByText('Success').closest('span')?.className).toContain('rounded-full');
});

it('applies outline shape classes by default', () => {
const { getByText } = render(<StatusBadge status={statusForTransaction('failed')} />);
expect(getByText('Failed').closest('span')?.className).toContain('border');
});

it('hides the icon when showIcon is false', () => {
const { container } = render(
<StatusBadge status={statusForTransaction('pending')} showIcon={false} />,
);
expect(container.querySelector('svg')).not.toBeInTheDocument();
});

it('shows an icon by default', () => {
const { container } = render(<StatusBadge status={statusForTransaction('pending')} />);
expect(container.querySelector('svg')).toBeInTheDocument();
});
});
67 changes: 67 additions & 0 deletions src/components/status/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<StatusTone, LucideIcon> = {
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 (
<span
title={status.detail}
className={`inline-flex items-center gap-1 text-xs font-semibold whitespace-nowrap ${shapeClasses} ${toneClasses} ${className}`}
>
{showIcon && <Icon size={iconSize} aria-hidden="true" />}
{status.label}
</span>
);
}
2 changes: 2 additions & 0 deletions src/components/status/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as StatusBadge } from './StatusBadge';
export type { StatusBadgeProps, StatusBadgeShape } from './StatusBadge';
19 changes: 4 additions & 15 deletions src/features/compliance/components/WhitelistManager.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -214,20 +216,7 @@ export default function WhitelistManager() {
{truncateAddress(entry.address)}
</td>
<td className="py-3 pr-4">
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${
entry.status === 'whitelisted'
? 'bg-emerald-50 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{entry.status === 'whitelisted' ? (
<ShieldCheck size={12} aria-hidden="true" />
) : (
<ShieldX size={12} aria-hidden="true" />
)}
{entry.status === 'whitelisted' ? 'Whitelisted' : 'Revoked'}
</span>
<StatusBadge status={statusForWhitelistEntry(entry.status)} variant="pill" />
</td>
<td className="py-3 pr-4 text-slate-500">{formatTimestamp(entry.updatedAt)}</td>
<td className="py-3 pr-4 text-slate-500">{entry.note ?? '\u2014'}</td>
Expand Down
18 changes: 10 additions & 8 deletions src/features/diagnostics/components/StatusCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`p-4 rounded-md border ${statusColors[status]}`}>
<div className={`p-4 rounded-md border ${toneClassName(tone, 'card')}`}>
<h3 className="font-semibold text-sm mb-1 opacity-80">{title}</h3>
<p className="font-mono text-sm break-all">{value}</p>
</div>
Expand Down
16 changes: 3 additions & 13 deletions src/features/issuer/components/IssuanceRequestsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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', {
Expand Down Expand Up @@ -201,11 +195,7 @@ export default function IssuanceRequestsTable({
</span>
</td>
<td className="p-2">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[req.status]}`}
>
{req.status}
</span>
<StatusBadge status={statusForIssuanceRequest(req.status)} variant="pill" />
</td>
<td className="p-2 text-xs text-slate-400">
{new Date(req.requestedAt).toLocaleDateString('en-US', {
Expand Down
Loading
Loading