# Component anatomy

> Every Lopifi component ships with this spec, filled in, in the order below. The order is the point: you cannot choose a font size on line 1, because layer 4 doesn't exist yet.

Source: https://lopifi.com/docs/craft/01-component-anatomy
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# 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

````markdown
# 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](../foundations/01-grid-and-layout.md#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

```ts
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 |
