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
classNamethroughcn()and spreads the rest of its props to its root element. - Layout components have no visual style; visual components have no layout opinion. A
Carddoesn't know it's in a grid; aGriddoesn'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"notoutlined. Booleans combine into invalid states; enums can't. - No
styleprop passthrough for design values. Consumers override viaclassNameand tokens. asChild(RadixSlot) on anything that might need to become a link.cva()for variants, so the class logic is data and can be read.forwardRef+displayNameon 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 |