# Primitives

> Build these first. Everything above them inherits their mistakes. Specs for 15 React components: Money, Delta, Figure, Percent and 11 more.

Source: https://lopifi.com/docs/catalog/01-primitives
Part of: Lopifi — US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# Primitives

Build these first. Everything above them inherits their mistakes.

---

## 1. `Money`

> The core primitive. Every monetary value in the system renders through it.

```tsx
<Money value={{ amount: 124056, currency: "USD" }} />        // $1,240.56
<Money value={{ amount: -124056, currency: "USD" }} accounting />  // ($1,240.56)
<Money value={null} />                                        // n/a
<Money value={{ amount: 0, currency: "USD" }} statement />    // —
```

```ts
interface MoneyProps extends React.ComponentPropsWithoutRef<"span"> {
  value: MoneyValue | null;
  /** parentheses for negatives (statements/tables) vs. a leading − (deltas) */
  accounting?: boolean;
  /** em dash for a true zero — statements only */
  statement?: boolean;
  size?: "xs" | "sm" | "md" | "lg" | "hero";
  /** subordinate the symbol; default true */
  showSymbol?: boolean;
  /** show the ISO code — required in mixed-currency contexts */
  showCode?: boolean;
  /** mark as converted: renders "≈" and requires a rate for the tooltip */
  approximate?: { rate: number; asOf: Date; from: string };
  redacted?: boolean;
  locale?: string;
}
```

**Layer 3 — structure:** `[≈] [symbol] [integer] [.decimals] [code]` — five slots, four optional. The paren column is reserved on both sides regardless of sign.

**Layer 4 — type:** one size, one weight. `tnum` + `case` always. Symbol and code at `--text-tertiary`; decimals at `--text-tertiary` only at `lg`/`hero`.

**Motion:** none, except the [value flash](https://lopifi.com/md/docs/motion/03-catalog#value-flash) when `live` and the [crossfade](https://lopifi.com/md/docs/motion/03-catalog) when the value changes from a user action. **Never interpolates.**

**Edge cases:** JPY 0dp · KWD 3dp · `bigint` amounts · negative zero (renders as zero) · the widest-plausible-value width reservation · `aria-label` announcing the sign in words.

**Why it matters:** if this is wrong, everything is wrong. It also carries the whole `case`/`tnum`/paren-alignment craft that the rest of the library assumes.

---

## 2. `Delta`

> A signed change. Never colored without a redundant cue.

```tsx
<Delta value={{ amount: 12400, currency: "USD" }} percent={5.3} polarity="standard" />
// ↑ +$12,400.00 (+5.3%)
<Delta value={{ amount: 8200, currency: "USD" }} polarity="inverted" />
// ↑ +$8,200.00   ← colored NEGATIVE, because up is bad for this metric
```

```ts
interface DeltaProps {
  value: MoneyValue | null;
  percent?: PercentChange;              // { kind: "points" | "relative"; value: number }
  polarity: "standard" | "inverted" | "neutral"; // required, no default
  showArrow?: boolean;                  // default true
  showPercent?: boolean;
  basis?: string;                       // "vs. Aug" — never omit the comparison basis
}
```

**The polarity contract is the whole point.** A `-4.2%` change in burn is good. Defaulting everything to `standard` is how dashboards report cheerful green on a cost overrun.

**API change:** `polarity` is now required and has no default (it previously fell back to `standard`). Callers that omitted it must choose: `standard` for revenue, cash and margin; `inverted` for cost, expense, DSO and burn; `neutral` for an FX rate or a headcount.

**Structure:** `[arrow] [sign] [value] [(percent)] [basis]`. Sign glyph slot reserved so a column of deltas aligns even when some are unchanged.

**Zero delta:** `—` with `--neutral-value`, no arrow. Not `+0.0%`.

**Motion:** none. Value flash when live.

---

## 3. `Figure`

> A large display numeral. The only place a figure may leave the figure face.

```tsx
<Figure value={{ amount: 24819024, currency: "USD" }} size="hero" family="display" />
```

**The step-down:** at `hero` (40px), a value exceeding a character threshold steps to `lg` (28px), then `md` (20px). Driven by a **container query on character count**, not `clamp()` — so the value always lands on a type-scale token rather than between two.

**The display gate:** `family="display"` is only valid when the component is provably solitary. In `KpiRow`, where figures align across cards, the prop is ignored and the figure face is forced. Enforce it in the parent, not by trusting the caller.

**Never truncates.** Escalation ladder in [numerics](https://lopifi.com/md/docs/foundations/06-numerics#overflow).

---

## 4. `Percent`

> Percent and percentage points are different numbers. This component makes conflating them impossible.

```tsx
<Percent value={{ kind: "relative", value: 25.0 }} />   // +25.0%
<Percent value={{ kind: "points", value: 5.0 }} />      // +5.0pp
<Percent value={{ kind: "bps", value: 25 }} />          // +25 bps
```

No default `kind`. The discriminator is required. Division by zero → `n/a`, never `Infinity`.

Precision by kind: relative/points 1dp · bps 0dp · rate 3dp.

---

## 5. `Rule`

> The accounting rule lines. Two CSS declarations, four centuries of grammar.

```tsx
<Rule variant="subtotal" />   // border-top: 1px solid --border
<Rule variant="total" />      // border-bottom: 3px double --border-strong
<Rule variant="section" />    // full-bleed 1px --border-subtle
```

**Spans the numeric column only**, not the label column. That's what makes it read as accounting rather than as a divider. In a multi-period statement it spans each period column independently, with the gutters unruled.

Never combined with a background fill on the same row.

Zero motion. Zero interactivity. Ten lines of code, and the single highest credibility-per-byte component in the library.

---

## 6. `StatusPill`

```tsx
<StatusPill status="pending" />       // ◷ Pending    amber, subtle bg
<StatusPill status="settled" />       // ✓ Settled    positive
<StatusPill status="failed" />        // ✕ Failed     negative
<StatusPill status="reconciled" />
<StatusPill status="needs-review" />
```

`*-subtle` background, `*` text, never a saturated fill. Icon + text always — the icon alone is not a status. Accessible name includes the entity: `"Payment pending"`.

**State change:** 140ms crossfade with a 2px blur mask, so the pill morphs rather than swapping. One of the small places the blur trick earns its keep.

---

## 7. `Sparkline`

24–40px tall, no axes, no gridlines, no tooltip by default. `--text-secondary` stroke at 1.5px, or `--positive`/`--negative` by net direction, or neutral when `polarity="neutral"`. `polarity` is required, no default — a falling burn line is good news.

Optional endpoint dot and a single label at the last point.

**Motion:** `stroke-dashoffset` draw, 600ms `--ease-out`, **once**, on first paint or scroll-into-view (`{ once: true }`). Never on data change.

**Touch:** no scrub. A sparkline is an impression, not a chart. If a user needs values, they need `MoneyChart`.

---

## 8. `Trend`
`Delta` + `Sparkline` composed, with aligned baselines. The default "how is this doing" unit inside a card. `polarity` is required, no default, and is passed to both halves.

## 9. `AsOf`

```tsx
<AsOf date={lastSync} />   // "2m ago"  ·  title="6 Sep 2026, 09:12 UTC"
```

Relative under an hour, absolute beyond. **Timezone always stated** in the absolute form. Updates from a single shared interval — never one `setInterval` per instance — and pauses on tab hide and off-screen.

Every figure in this system is accompanied by one of these. A balance without an "as of" is a rumor.

## 10. `Redacted`
`••••` at `--text-tertiary`, reveal on explicit action. Layout-stable: the masked form reserves the revealed width. Privacy-blur variant uses `filter: blur(6px)` + `user-select: none`, crossfading over 140ms so nothing reflows.

## 11. `Reference`
Transaction and account IDs. `"zero" 1, "ss02" 1` so `0`/`O` and `1`/`l`/`I` are unambiguous — these get transcribed by humans into other systems. Click to copy; copies the raw value. Truncates in the middle (`txn_8a2…f091`), never at the end, because the tail is the discriminating part.

## 12. `CurrencyBadge`
ISO code, optionally with a flag. Flags are **regional**, not linguistic — EUR gets the EU flag, and any currency without a clean single-country mapping gets no flag rather than a wrong one. Code is always present; the flag is decoration.

## 13. `EntityMark`
Bank and merchant logos. Rounded-square container at radius 8, `--surface-sunken` background, ~20% inset, `object-fit: contain`, `saturate(0.85)` at row scale. Full fallback ladder — provided → resolved → deterministic monogram → category icon. Never a broken image. Spec in [imagery](https://lopifi.com/md/docs/foundations/08-imagery-and-isometric#logo-treatment).

---

## 169. `Tooltip`

> A restatement of something already on screen. Never the only home of a fact.

```tsx
<Tooltip content="30 Sep 2026, 09:12 UTC">
  <time>2m ago</time>
</Tooltip>

<TooltipProvider delay={400}>{/* a toolbar, a table, an app */}</TooltipProvider>
```

```ts
interface TooltipProps {
  /** Nullable on purpose: a truncated cell has one, a short cell does not. */
  content: React.ReactNode;
  children: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
  /** Overrides the provider's delay for this one trigger. */
  delay?: number;
  disabled?: boolean;
  /** `block` for a child that fills its column — a truncating cell. */
  display?: "inline" | "block";
  triggerClassName?: string;
}
```

**It renders its own trigger span** and puts `children` inside it, rather than cloning the
caller's element. That is not a stylistic choice: a Server Component passes its JSX children
across the boundary as a **lazy node**, not an element — `isValidElement` is false and `type` is
`undefined` — so a `render={children}` clone renders `undefined` and takes the page down with it.
Rendering the child as a child works from either side of the boundary. The cost is one `<span>`,
and `display="block"` is how a truncating child keeps its width.

**What may go in one.** The unabbreviated figure behind `$1.2M`. The absolute timestamp behind
"2m ago". The untruncated merchant name behind an ellipsis. The rate and instant behind a `≈`
conversion. The comparison basis behind a delta on a card too narrow to carry it inline. In every
case the tooltip restates — it never introduces.

**What may not.** Anything a user must read to act. **Tooltips do not exist on touch**, so a
sentence that only lives in one is a sentence a phone user never sees. If it is essential, inline
it; if it is additional, it belongs in an [`InfoTip`](https://lopifi.com/md/docs/catalog/01-primitives#170-infotip), which opens on press.

**Layer 3 — structure:** radius 8, `--surface-raised`, hairline + `--elevation-2`, 12px caption,
max-width 264px, 8px from its anchor.

**Motion:** enter `opacity 0→1, scale 0.97→1` over 140ms `--ease-out`; exit opacity over 100ms;
400ms delay before the *first* one. Once one is open, hovering a neighbour opens it instantly —
Base UI sets `data-instant`, and the transition duration must go to `0ms` with it or the "instant"
tooltip still visibly fades. Wrap a dense surface in `TooltipProvider` to get that grouping.

**Accessibility:** the trigger keeps its own accessible name; the tooltip is `aria-describedby`.
It opens on keyboard focus, closes on Escape, and is never the target of a click.

---

## 170. `InfoTip`

> The ⓘ. Additional context, reachable on every input.

```tsx
<InfoTip about="Why this account needs reconnecting" label="This balance may be out of date">
  Mercury revoked this connection's authorization. Last synced 21 Sep 2026, 09:12 UTC.
</InfoTip>
```

```ts
interface InfoTipProps {
  /** An optional bolded first line. A term; omit it for a plain sentence. */
  label?: React.ReactNode;
  children: React.ReactNode;
  /** The affordance's accessible name — "About available balance". */
  about: string;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
  /** Replaces the ⓘ glyph — a warning triangle beside a broken state, say. */
  trigger?: React.ReactNode;
}
```

A **Popover**, not a Tooltip, and that is the entire design. It opens on hover on a fine pointer,
on press everywhere, and on focus from the keyboard — so the sentence inside it is reachable on a
phone, which is what licenses putting a real sentence there at all. Escape and an outside press
close it. 16px glyph, 40px hit area, `--text-tertiary` at rest.

**Use it to shorten an alert.** `"Reauthorization required — this balance may be out of date"` in a
one-third-width card wraps to three lines and reads as a paragraph in a strip meant to read as a
state. `"Reauthorization required"` plus an ⓘ says the same thing in one line, and the explanation
is one press away instead of permanently in the way.

**Never** put an action, a link the user must follow, or the only copy of a number inside one.
