# Icons & microcopy

> Lucide, 1.5px stroke, at 16 / 20 / 24. It's shadcn's default, it's MIT, it's comprehensive, and its optical grid matches Inter's rhythm.

Source: https://lopifi.com/docs/foundations/07-icons-and-microcopy
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Icons & microcopy

---

## Icons

### The set
**Lucide**, 1.5px stroke, at 16 / 20 / 24. It's shadcn's default, it's MIT, it's comprehensive, and its optical grid matches Inter's rhythm.

```css
--icon-sm: 16px;   /* inline with body/label text, table cells, buttons */
--icon-md: 20px;   /* standalone buttons, nav items, card headers */
--icon-lg: 24px;   /* empty states, feature rows, mobile nav */
--icon-stroke: 1.5;
```

**Never scale an icon off-grid.** A 16px icon rendered at 18px has a 1.33px stroke and looks blurry against 1.5px siblings. Three sizes is enough.

### Stroke consistency
The stroke width is fixed at **1.5 across all sizes**. Lucide's default is 2, which is too heavy next to Inter 400 at 14px — it makes the icon shout over its own label. Set it once, globally:

```tsx
<LucideIcon strokeWidth={1.5} />
```

The alternative (scaling stroke with size, so 24px icons get 2px) is more "correct" optically but produces visible inconsistency when a 16px and a 24px icon appear in the same view, which happens constantly. Fixed 1.5 wins on coherence.

### Alignment
Icons align to **cap height**, not line box. Offsets are in [grid & layout](01-grid-and-layout.md#optical-alignment). Lopifi ships an `<InlineIcon>` that applies the correct offset for its size pair, because relying on every consumer to remember `-translate-y-[0.5px]` doesn't work.

### Color
- Icons default to **`--text-secondary`**, one step down from their label. An icon at the same color as its text competes with it.
- An icon that *is* the only content (icon-only button) gets `--text-primary`.
- Semantic icons take the semantic color and **must not be the only signal** — a red circle without text is not a status.
- **Never `--accent` on an icon** unless it's inside the one primary action.

### Finance icon vocabulary
Consistency matters more than cleverness. Fixed mappings:

| Concept | Icon | Note |
|---|---|---|
| Increase / up | `arrow-up-right` | Diagonal, not vertical — vertical reads as "sort" |
| Decrease / down | `arrow-down-right` | |
| Unchanged | `minus` | Not a dash glyph — an icon, for alignment |
| Transfer / move money | `arrow-right-left` | |
| Swap / convert | `arrow-left-right` in a circle | Rotates 180° on flip — see [motion catalog](../motion/03-catalog.md) |
| Pending | `clock` | Never a spinner — pending is a state, not a load |
| Reconciled / matched | `check` in a circle | |
| Failed | `x` in a circle | |
| Needs review | `alert-triangle` | Amber. Reserve for genuine action-required. |
| Locked / final period | `lock` | |
| Recurring | `repeat` | |
| Attachment / receipt | `paperclip` | |
| Export | `arrow-down-to-line` | Not `download` (a cloud) — this is a file, not a fetch |
| Drill down | `chevron-right` | Rotates 90° when expanded |
| Bank account | `landmark` | |
| Card | `credit-card` | |
| Cash | `banknote` | |

**No emoji.** Anywhere. In any component. They render differently on every platform, they're untintable, and they instantly cost a financial product its seriousness.

### Icon transitions
An icon that changes meaning **crossfades**, blur-masked, 140ms — the same treatment as a changed value ([why the blur](../motion/01-principles.md#blur-as-a-transition-mask)). It does not morph.

Path morphing — interpolating one outline into another, per `raphaelsalaja/morphing-icons` — is well-made work and wrong here. It needs both icons authored as a single path with matching point counts, which Lucide doesn't give us and which breaks the moment an icon is swapped; it animates `d`, which is neither `transform` nor `opacity`, on a surface that may have forty siblings; and its entire appeal is drawing the eye to the transition, when the thing that changed is the **state**, not the icon.

Two exceptions, and both are rotations rather than morphs — a `transform`, free on the compositor:

| Case | Motion |
|---|---|
| Swap direction inverted | `arrow-left-right` rotates 180°, `spring.playful` |
| Disclosure opened | `chevron-right` rotates 90°, 140ms `--ease-standard` |

Everything else — pending → reconciled, unlocked → locked, synced → stale — is a crossfade. The vocabulary above is fixed, so a swap between two of its members is already legible without being animated into.

### Icon-only buttons
Always `aria-label`. Always a tooltip. Always ≥ 40px hit area even when the icon is 16px (padding, or a pseudo-element hit-slop). Non-negotiable — this is the most common accessibility failure in dense toolbars.

```tsx
<button aria-label="Export transactions" className="size-10 grid place-items-center">
  <ArrowDownToLine className="size-4" strokeWidth={1.5} aria-hidden />
</button>
```

Note `size-*` rather than `w-* h-*` for square elements — a baseline-ui rule and it does read better.

### Brand and bank logos
Not icons. See [imagery](08-imagery-and-isometric.md#logo-treatment).

---

## Microcopy

The words are part of the design. In finance they're also part of the correctness.

### Voice
**Precise, plain, and unhedged.** Not friendly, not clever, not apologetic.

| Don't | Do |
|---|---|
| "Oops! Something went wrong 😅" | "Couldn't load transactions." |
| "You're all caught up! 🎉" | "No transactions in this period." |
| "Awesome, payment sent!" | "Payment sent. Arrives 8 Sep." |
| "Are you sure?" | "Send $12,400.00 to Northwind Ltd?" |
| "Invalid amount" | "Enter an amount of $0.01 or more." |
| "Processing…" | "Submitted to bank · 2m ago" |

### The lexicon
One word per concept, used everywhere. Drift here is why financial products feel incoherent.

| Use | Never |
|---|---|
| **Transaction** (a movement of money) | activity, item, entry (reserve *entry* for journal entries) |
| **Balance** (a point-in-time amount) | total, funds, money |
| **Available** vs **Current** balance | "your money" — the distinction is material |
| **Pending** (submitted, not settled) | processing, in progress, on the way |
| **Settled** / **Cleared** | complete, done, finished |
| **Reconciled** (matched to a statement) | verified, confirmed, checked |
| **Categorized** | tagged, labeled, sorted |
| **Period** (a fiscal window) | date range, timeframe |
| **Statement** (P&L, BS, CF) | report — a *report* is something you generate |
| **Account** (a GL account or a bank account — always qualify which) | bare "account" in mixed context |
| **Payee** / **Recipient** (outbound) vs **Payer** (inbound) | customer, vendor, unless you mean specifically that |
| **Fee** (a charge we levy) vs **Cost** (spread, FX margin) | "charges" |
| **Rate** (FX) vs **Interest rate** | always qualify |

### Numbers in copy
- **Always the exact amount in a confirmation.** "Send $12,400.00" — never "Send this payment".
- **Always the destination.** "to Northwind Ltd ••••4291".
- **Always the timing, if known.** "Arrives 8 Sep" beats "Sent successfully" because it answers the next question.
- **Never a number in a heading that also appears in the body.** Pick one.

### Errors
Per baseline-ui and [accessibility](../craft/02-accessibility.md):
- **Next to where the action happened**, not in a toast at the corner of the screen. A field error goes under the field.
- **Say what to do**, not what failed. "Enter a valid routing number (9 digits)" not "Invalid input".
- **Never blame the user.** "That routing number isn't recognized" not "You entered an invalid routing number".
- **A toast is never the only channel for a critical error.** Toasts are dismissible, timed, and easy to miss. Persist the error inline.
- **Financial errors state the consequence.** "Payment not sent. No money has left your account." The reassurance is the most important sentence.

### Empty states
Exactly one clear next action. Always.

```
  [isometric illustration, ≤ 160px]
  No transactions yet                        title-sm  16/24 w500
  Connect a bank account to import           body      14/20 w400  text-secondary
  activity automatically.
  [ Connect account ]                        primary button
```

Three variants, and they are different components:
- **Empty (first run)** — illustration + one primary action. This is onboarding.
- **Empty (filtered)** — no illustration, text only, and a "Clear filters" action. An illustration here is condescending; the user knows what happened.
- **Empty (period)** — "No transactions between 1–30 Sep." Restate the resolved period, offer to widen it.

### Buttons
- **Verb + object.** "Send payment", not "Submit" or "Continue".
- **The destructive button names the destruction.** "Delete 42 transactions", not "Delete".
- **Never "OK".** The button says what it does.
- **Cancel is always available and never destructive-styled.**
- **Sentence case**, never Title Case, never ALL CAPS.

### Confirmations for irreversible actions
An `AlertDialog`, and it restates the three facts that matter — **amount, destination, timing**:

> **Send $12,400.00 to Northwind Ltd?**
> To account ••••4291 · Arrives 8 Sep
> This can't be undone once submitted.
> `[Cancel]` `[Hold to send →]`

For genuinely irreversible movements of money (wires, ACH after cutoff), the confirm is **hold-to-confirm**, not click. Specification in [motion catalog](../motion/03-catalog.md#hold-to-confirm) — it's the one place we deliberately make an interaction *slower*, because the user should be deciding for those 1.5 seconds.

### Tone in loading and pending
Never "Please wait". State what's happening and, if you know it, how long:

- `"Importing 1,284 transactions…"` — with a determinate progress bar if you have a count
- `"Submitted to bank · settles in 1–2 business days"`
- `"Syncing with QuickBooks · last synced 4m ago"`

The last one is the pattern: **current state + last known good state.** It means the UI is still useful while it's stale.
