# CYDM — Design System

> **Constraint: blue and orange only.** Every institution customises within that frame, never outside it.

---

## 0. The brand colours are sampled, not chosen

Both hues come from the CYDM logo itself, read pixel-by-pixel rather than
eyeballed:

| | Hex | OKLCH | Where it comes from |
|---|---|---|---|
| **Blue** | `#1800AC` | `oklch(0.344 0.228 268.6)` | The "CYDM" letterforms |
| **Orange** | `#FF741F` | `oklch(0.715 0.190 46.0)` | The swoosh |

Two consequences worth knowing:

**The blue is an indigo, not a mid-blue.** At hue 268.6 it sits outside the
220–260 range this document originally called "the blue family" — that range
would have rejected the actual brand mark. The permitted range is therefore
**240–285** for blue and **30–60** for orange. When a validator disagrees with
the brand, the validator is what's wrong.

**It is dark — L = 0.344.** That is excellent for a primary button (roughly 13:1
against white, far past AA) and nearly invisible against a dark background. It
is the concrete reason the dark palette is re-derived rather than inverted: dark
mode lifts the primary to `L = 0.62` while holding hue and dropping chroma
slightly.

Assets are generated from the master logo by `scripts/build-brand-assets.php` —
trimmed wordmark, square mark, favicon set. Re-run it if the logo changes.

---

## 1. Why the palette is locked

Most white-label platforms let tenants pick any colour. That produces two failures: institutions choose combinations that fail contrast requirements, and the product stops looking like one product.

CYDM fixes the hues and lets tenants vary lightness, chroma and radius within them.

- **Blue** carries trust and stability — the universal language of banking. It is the primary: navigation, primary actions, focus rings, links.
- **Orange** carries warmth and energy — the community half of community finance. It is the accent, used sparingly: highlights, secondary emphasis, empty-state illustration.
- The pair is **safe for deuteranopia and protanopia**, the common forms of colour blindness. Red/green would not be, which matters when status is being communicated.
- Locking hues means every contrast pairing can be verified once, centrally, rather than trusted to each tenant.

**Orange is an accent, not a co-primary.** Roughly 10% of coloured surface area. When everything is emphasised, nothing is.

---

## 2. Tokens

Colours are authored in **OKLCH**, not hex. OKLCH is perceptually uniform: equal lightness numbers look equally light across hues, so a tenant sliding lightness cannot accidentally destroy contrast the way hex manipulation does.

Format: `L C H` — Lightness (0–1), Chroma (0–0.4), Hue (0–360).

```css
/* resources/css/app.css */

:root {
  --radius: 0.625rem;

  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);

  /* PRIMARY — blue. Trust, finance, navigation, primary action. */
  --primary: oklch(0.344 0.228 268.6);
  --primary-foreground: oklch(0.985 0 0);

  /* ACCENT — orange. Warmth, community, sparing emphasis. */
  --accent: oklch(0.715 0.19 46);
  --accent-foreground: oklch(0.145 0 0);

  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);

  --destructive: oklch(0.577 0.245 27.325);
  --destructive-foreground: oklch(0.985 0 0);

  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: var(--primary);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);

  /* Lifted for dark backgrounds — the light-mode blue reads as muddy on dark. */
  --primary: oklch(0.62 0.21 268.6);
  --primary-foreground: oklch(0.145 0 0);
  --accent: oklch(0.76 0.18 46);
  --accent-foreground: oklch(0.145 0 0);

  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);

  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: var(--primary);
}
```

Dark mode is **not** an inversion. Lightness values are re-chosen per token so that contrast holds in both directions.

---

## 3. Financial status colours

Loan classification is regulated and appears throughout the product. It needs its own scale — and it must never rely on colour alone, because status drives decisions and some users cannot distinguish the hues.

Buckets and thresholds are per **GN 679 Reg 45(1)–(2)** — see
[COMPLIANCE.md](COMPLIANCE.md) and [features/03-loans.md](features/03-loans.md).

| Classification | Days past due | Provision | Token | Also signalled by |
|---|---|---|---|---|
| Current | 0–5 | 1% | `--status-current` (green) | ✓ icon, text label |
| Especially Mentioned | 6–30 | 5% | `--status-mentioned` (amber) | ⚠ icon, text label |
| Substandard | 31–60 | 25% | `--status-substandard` (orange) | ▲ icon, text label |
| Doubtful | 61–90 | 50% | `--status-doubtful` (deep orange) | ▲ icon, text label |
| Loss | over 90 | 100% | `--status-loss` (red) | ✕ icon, text label |

Housing microfinance uses the separate Reg 45(3)–(4) schedule (91–180 / 180–360
/ 361+) with the same five tokens.

**Every status badge carries an icon and a written label.** Colour is reinforcement, never the sole carrier of meaning.

---

## 4. Per-tenant customisation

Tenants pick from curated palettes, not a free colour picker.

```ts
const BLUE_PALETTES = [
  { name: 'CYDM Indigo',       primary: '0.344 0.228 268.6' }, // DEFAULT — the brand mark
  { name: 'Deep Trust',        primary: '0.30 0.19 265' },     // conservative
  { name: 'Bright Confidence', primary: '0.48 0.22 264' },     // digital-first
  { name: 'Navy Authority',    primary: '0.32 0.15 258' },     // traditional
];

const ORANGE_PALETTES = [
  { name: 'CYDM Orange',   accent: '0.715 0.19 46' },          // DEFAULT — the swoosh
  { name: 'Warm Community', accent: '0.68 0.16 42' },
  { name: 'Amber Warmth',   accent: '0.72 0.15 38' },
  { name: 'Sunrise Energy', accent: '0.78 0.18 55' },
];
```

The CYDM defaults are first in each list because they are the sampled brand
values; the alternatives are variations around them, all inside the permitted
hue ranges.

Server-side validation rejects anything outside the permitted hue ranges (blue 240–285, orange 30–60) — the UI offers presets, but the API is the actual boundary, since branding can also be set through it.

### Avoiding the flash of default theme

Applying tenant colours from a `useEffect` means the first paint uses CYDM defaults and then visibly snaps to the tenant's — cheap-looking, and worse on slow connections common in the field.

Instead, middleware injects the tenant's variables as an inline `<style>` in the initial HTML, so the first paint is already correct. The React hook then handles later changes (live preview in the branding editor).

---

## 5. Typography

| Role | Value |
|---|---|
| UI font | Instrument Sans (starter kit default) |
| Numerals | **Tabular figures** — `font-variant-numeric: tabular-nums` |
| Base size | 16px — never smaller for body text |
| Scale | 12 / 14 / 16 / 18 / 20 / 24 / 30 / 36 |

**Tabular figures are mandatory for every monetary column.** With proportional digits, `1,111` and `9,999` render at different widths and columns of currency fail to align — which makes scanning a ledger genuinely harder and looks unprofessional to an auditor.

### Money formatting

```
TZS 1,234,567          amounts ≥ 1
TZS 1,234,567.89       where cents are significant
(TZS 1,234,567)        negative — parentheses, accounting convention
—                      zero or null, never "TZS 0" in a summary
```

Always right-aligned. Always tabular. The currency code precedes the amount, per Tanzanian convention.

---

## 6. Component inventory

Built on **shadcn/ui** — components are copied into the repo rather than imported, so they can be adapted. `components.json` is configured for CSS-variable mode, which per-tenant theming requires.

**Base:** button, input, label, select, checkbox, radio-group, switch, textarea, form
**Layout:** card, separator, sheet, sidebar, tabs, accordion, collapsible
**Feedback:** alert, toast/sonner, dialog, alert-dialog, progress, skeleton
**Data:** table, data-table, pagination, badge, avatar, tooltip, hover-card, popover, command
**Charts:** area, bar, line, pie (Recharts wrappers)
**Domain (ours):** `MoneyInput`, `MoneyDisplay`, `StatusBadge`, `MemberCard`, `LoanScheduleTable`, `KpiTile`, `PhoneInput` (+255 aware), `NidaInput` (format-masked), `DateInput` (DD/MM/YYYY)

Domain components exist so that money is never formatted ad hoc in a page. One `MoneyDisplay` means one place to fix alignment, rounding and negative-number presentation.

---

## 7. Layout

12-column grid, 4px spacing base. Breakpoints follow Tailwind defaults (sm 640 / md 768 / lg 1024 / xl 1280).

**Mobile matters more than the desktop-first instinct suggests.** Loan officers do field verification, group meetings and collections on phones, often on slow connections. Every screen is usable at 375px; tables collapse into cards rather than scrolling horizontally.

The app shell: fixed sidebar (collapsible to icons), sticky header with breadcrumbs and user menu, content area capped at `max-w-7xl`.

---

## 8. Accessibility

Not optional — consumer-protection regulation expects the product to be usable, and members span a wide range of literacy and vision.

| Requirement | How |
|---|---|
| Contrast | 4.5:1 body text, 3:1 UI. Foreground tokens auto-computed and validated. |
| Colour independence | Status always carries icon + label |
| Focus | `--ring` tracks primary; visible `focus-visible` on every interactive element |
| Keyboard | Full navigation; no mouse-only interactions |
| Motion | `prefers-reduced-motion` disables transitions |
| Zoom | `rem` throughout; usable to 200% |
| Labels | Every input labelled; errors linked via `aria-describedby` |

---

## 9. Bilingual layout

Swahili and English are both first-class, and **Swahili strings run noticeably longer than English** — commonly 20–30%. "Loan" is "Mkopo"; but "Repayment Schedule" is "Ratiba ya Marejesho". Fixed-width buttons sized to English will clip.

Rules that follow from this:

- Buttons and labels size to content; no fixed widths on text containers.
- Navigation is tested in Swahili, not just English — it is the longer case.
- Truncation always carries a tooltip with the full string.
- Dates are `DD/MM/YYYY` in both languages.

---

## 10. Writing style

UI copy is plain, direct, and translatable.

- Prefer "Money in" over "Deposit transaction posting".
- Errors say what to do: "Enter an amount greater than TZS 1,000", not "Invalid input".
- Never blame the user. "That NIDA number wasn't found" — not "You entered an invalid NIDA".
- Avoid idiom; it does not survive translation.
- Financial terms use the member-facing word, with the regulatory term available on hover where the two differ.
