Skip to content

Component anatomy

Every Lopifi component ships with this spec, filled in, in the order below. The spec is written before the code, reviewed at each layer, and lives in the component's README.md next to its source.

The order is the point: you cannot choose a font size on line 1, because layer 4 doesn't exist yet.


The template

# ComponentName

> One sentence. What it is and when to reach for it.

**Status:** draft | spec'd | built | stable
**Depends on:** @lopifi/money · shadcn/button
**Composes into:** BalanceCard, AccountList

---

## Layer 1 — Grid

Base unit          8px
Outer radius       24
Inset              24
Min width          240px
Height             auto | fixed
Grid placement     span 1 of 12 | full-bleed
Breakpoint changes what re-ranks below 768 / 640

## Layer 2 — Safe space & grouping

┌─ 24 ─────────────────────────────┐
│  [eyebrow]                        │
│   8                               │
│  [figure]                         │
│   8                               │
│  [delta]                          │
│  16                               │
│  ├─ full-bleed rule ────────────┤ │
│  16                               │
│  [actions]                        │
└─ 24 ─────────────────────────────┘

Groups             (eyebrow + figure + delta) = one group, 8px internal
                   actions = separate group, 16px away
Full-bleed         the rule only

## Layer 3 — Structure

Slot        Req?  Lines  Overflow            Notes
eyebrow     opt   1      truncate            max 3 words
figure      REQ   1      never — step down   see numerics#overflow
delta       opt   1      truncate            reserves sign glyph slot
media       opt   —      aspect-[3/1]        full-bleed bottom
actions     opt   —      —                   max 2, max 1 primary

Ceilings           3 text slots · 2 actions · 1 media

## Layer 4 — Type

Slot      Token      Size/Line  Weight  Color
eyebrow   label      13/16      500     --text-secondary
figure    figure-lg  28/32      500     --text-primary
delta     caption    12/16      400     --positive | --negative | --text-secondary

Count: 3 sizes, 2 weights ✓

---

## Data contract

```ts
interface ComponentNameProps extends React.ComponentPropsWithoutRef<"div"> {
  value: Money | null;
  polarity?: "standard" | "inverted" | "neutral";
  // …
}
```

Null handling      value: null → "n/a"  ·  value.amount === 0 → "$0.00"
Loading            skeleton matching the exact structure above
Error              inline, at the slot that failed

## Motion

Frequency tier     …
Purpose            …
Enter / Exit       …
Gesture            …
Reduced motion     …
Perf note          …

## Responsive

≥1024   full layout
768     …
<640    …
Touch   …

## Accessibility

Role / semantics   …
Accessible name    …
Keyboard           …
Focus              …
Announcements      …
Contrast           …

## Edge cases

☐ Negative value            ☐ Zero vs null vs redacted
☐ Very large value          ☐ Very long label
☐ Non-USD / JPY (0dp) / KWD (3dp)
☐ Multi-currency            ☐ RTL
☐ Loading · error · empty · offline/stale
☐ Reduced motion            ☐ Forced colors
☐ 320px width               ☐ 200% browser zoom
☐ Light + dark              ☐ Signal palette

## Examples

Default · with delta · loading · error · empty · dense · dark · RTL

Working the layers

Layer 1 — Grid

Deliverable: an 8px lattice with the component's bounding box, radius, and inset. Nothing else.

Answers: how wide, how tall, what radius, what inset, how it sits in the page grid, what happens at each breakpoint.

Rejection criteria: any off-grid value; a radius not on the scale; an inset that isn't 16 or 24; a fixed height where content is variable.

Layer 2 — Safe space & grouping

Deliverable: grey blocks showing padded regions and gaps. Still no content.

Answers: what belongs with what, and how far apart. Every gap comes from the proximity ladder — 4/8/16/24/32-48, no others.

Rejection criteria: more than five distinct gap values; a gap off the ladder; content bleeding into the inset without one of the three sanctioned reasons.

Layer 3 — Structure

Deliverable: the slot table plus a wireframe. Grey boxes, no type, no color, no icons.

This is where over-design gets caught, because it is very hard to justify a fourth grey rectangle. Every slot declares required/optional, line count, and overflow behavior.

Rejection criteria: more than 3 text slots; a slot without declared overflow behavior; a figure slot that truncates; unbounded action count; a fixed-height media slot.

Layer 4 — Type

Deliverable: the type table, with the count at the bottom.

Max 3 sizes, max 3 weights. The count is written out so the violation is visible in review rather than discovered later.

Rejection criteria: 4+ sizes or weights; a size not on the scale; the display family on anything that could align with another figure; a monetary value at --text-tertiary; a label out-ranking its value.


Composition rules

  • Composition over configuration. <BalanceCard><Money/><Delta/></BalanceCard> beats <BalanceCard showDelta deltaFormat="percent" />. Boolean props multiply; slots don't.
  • Two levels of nesting, maximum. A card in a card in a card is a hierarchy failure — the innermost should be a section with a rule line.
  • A component owns its own padding, never its own margin. Margins are the parent's business. This is what makes components droppable anywhere.
  • Every component forwards className through cn() and spreads the rest of its props to its root element.
  • Layout components have no visual style; visual components have no layout opinion. A Card doesn't know it's in a grid; a Grid doesn't know it holds cards.

Props conventions

interface Props extends React.ComponentPropsWithoutRef<"div"> {
  // 1. Data
  value: Money | null;
  previous?: Money | null;

  // 2. Semantics
  polarity?: "standard" | "inverted" | "neutral";

  // 3. Presentation — enumerations, never booleans
  size?: "sm" | "md" | "lg";
  variant?: "default" | "outline" | "ghost";
  density?: "compact" | "default" | "comfortable";

  // 4. State
  state?: "default" | "loading" | "error" | "empty";

  // 5. Slots — ReactNode, not config objects
  action?: React.ReactNode;
  media?: React.ReactNode;

  // 6. Escape hatches
  className?: string;
  asChild?: boolean;
}

Rules:

  • No boolean props for visual variants. variant="outline" not outlined. Booleans combine into invalid states; enums can't.
  • No style prop passthrough for design values. Consumers override via className and tokens.
  • asChild (Radix Slot) on anything that might need to become a link.
  • cva() for variants, so the class logic is data and can be read.
  • forwardRef + displayName on every primitive.
  • Named exports only. No default exports, no barrel files.

The edge-case checklist

This is why the library exists. Every component is tested against all of these before it's stable.

Case The failure it prevents
Negative value Layout shifting because of the paren; wrong color polarity
Zero vs null vs redacted Reporting $0.00 for data we don't have
Very large value ($1,284,392,110.55) Truncation, wrapping, or a card blowing out
Very long label ("Accounts Receivable — Intercompany") Pushing the figure off-screen
JPY (0 decimals) / KWD (3 decimals) Hard-coded toFixed(2)
Multi-currency in one list A meaningless sum; ambiguous $
RTL Number direction, icon mirroring, statement indentation
Loading A skeleton that doesn't match, causing a jump
Error A component that renders nothing and looks broken
Empty An empty state with no next action
Offline / stale No "as of" indication, so a stale number reads as current
Reduced motion Motion sickness; blocked content
Forced colors / high contrast Invisible borders, invisible focus rings
320px width Horizontal page scroll
200% browser zoom Overlap, clipped content, unreachable actions
Dark mode Contrast failures, invisible hairlines
Signal palette Sign readable without red/green
Greyscale Every sign still readable — the ship gate

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.