Repo & conventions
How the Lopifi repo is laid out, how files and tokens are named, the shape every component follows, and the tests, commands and contribution rules a change has to pass.
finance-blocks/
├── README.md
├── DESIGN.md ← machine-readable token contract
├── registry.json
├── docs/ ← the specs; source of truth for behavior
│ └── catalog/index.md the component manifest the generators parse
│
├── src/
│ ├── ledger/ the components, as they'll be copied
│ │ ├── tokens.css the single source of token truth
│ │ ├── preset.ts Tailwind v3 preset (ships with @lopifi/theme)
│ │ ├── theme-v4.css Tailwind v4 @theme
│ │ ├── lib/ money.types.ts, format-money.ts, allocate.ts,
│ │ │ fiscal-period.ts, cn.ts, …
│ │ ├── hooks/ use-density.ts, use-live-value.ts,
│ │ │ use-column-priority.ts, …
│ │ ├── primitives/ the only files that import Base UI
│ │ └── components/ money.tsx, …
│ ├── lib/*.generated.json build output, never edited by hand
│ ├── tools/ the toolbelt: lib/ arithmetic, specs/, spec.ts
│ ├── playground/ the workbook's grid and sheets
│ ├── learn/ the course
│ └── app/ Next.js App Router: docs site, llms.txt, registry, MCP server
│
├── extension/ the Chrome extension (MV3)
├── tests/ Vitest
├── public/r/ the published registry
│
└── scripts/
├── build-catalog.mjs catalog, docs index
├── build-glossary.mjs glossary from the lexicon and metrics docs
├── build-registry.mjs registry items from the imports
└── build-*.mjs the other generators, all run by `bun run gen`
Naming#
| Thing | Convention | Example |
|---|---|---|
| Files | kebab-case | balance-card.tsx |
| Components | PascalCase | BalanceCard |
| Hooks | use-{name}.ts |
use-density.ts |
| Props types | {Component}Props |
BalanceCardProps |
| Constants | UPPER_SNAKE | DEFAULT_DENSITY |
| CSS vars | kebab, semantic-first | --text-secondary, not --gray-600 |
| Registry items | kebab, matching the file | balance-card |
Tokens are named by role, never by value. Write --text-secondary instead of --gray-600 and --positive instead of --green. You can't re-theme a token named after its value, and renaming it later is a breaking change.
File conventions#
- Named exports only. No default exports and no barrel files. Direct imports keep the registry's file graph accurate and tree-shaking predictable.
- One component per file. Sub-parts that are never used on their own live in the same file (
Card,CardHeader,CardTitleincard.tsx). "use client"only when interaction demands it.Money,Figure,StatementRowandRulerender in bulk, so they stay server-safe.- Every component has a spec in
docs/catalog/with the four layers from component anatomy. The registry and the docs site are generated from it, so a consumer who installs the component can read the reasoning next to it. - Imports:
@/*→./src/*. Consumers get@/components/ledger/*.
Component skeleton#
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/ledger/cn";
const balanceCardVariants = cva(
"relative rounded-xl border border-[--border] bg-[--surface] p-6",
{
variants: {
size: { sm: "p-4", md: "p-6", lg: "p-6" },
interactive: {
true: "transition-transform duration-1 ease-out active:scale-[0.97] hover:-translate-y-px hover:shadow-[--elevation-1]",
false: "",
},
},
defaultVariants: { size: "md", interactive: false },
},
);
export interface BalanceCardProps
extends React.ComponentPropsWithoutRef<"div">,
VariantProps<typeof balanceCardVariants> {
value: Money | null;
polarity: Polarity; // required, no default
action?: React.ReactNode;
}
export const BalanceCard = React.forwardRef<HTMLDivElement, BalanceCardProps>(
({ className, size, interactive, value, polarity = "standard", action, ...props }, ref) => (
<div ref={ref} className={cn(balanceCardVariants({ size, interactive }), className)} {...props}>
{/* … */}
</div>
),
);
BalanceCard.displayName = "BalanceCard";
Every primitive: cva for variants, forwardRef, displayName, cn() merging a className, props spread to the root, extends the native element's props.
Testing#
| Layer | Tool | What |
|---|---|---|
| Unit | Vitest | Formatters, allocation, fiscal periods. Property-based tests on money math over random amounts and currencies (planned; today these are example-based). |
| Component | Vitest + Testing Library (planned, not built) | Every state in the edge-case checklist |
| A11y | axe-core per story (planned, not built) |
Blocking in CI |
| Visual | Playwright screenshots (planned, not built) | Every component × {light, dark} × {default, compact} × {320, 768, 1280} |
| Motion | Playwright traces (planned, not built) | Assert compositor-only: no layout/paint entries during an animation |
| Tokens | scripts/check-tokens.ts (planned, not built) |
Contrast ratios computed from tokens; grid conformance on every spacing value |
| Registry | scripts/check-registry.ts (planned, not built) |
Every item installs into a clean app and typechecks |
The money math should get property-based tests. formatMoney(parseMoney(x)) === x for all valid inputs, and allocate(total, weights).sum() === total for all inputs. Every component depends on those two holding, and a handful of hand-picked examples won't find the amount that breaks them.
Commands#
bun run gen # catalog, docs index, glossary, registry (/r/*.json), tool registry
bun run dev # gen, then the docs site on :3210
bun run build # gen, then next build
bun run test # vitest
bun run lint # biome lint
bun run typecheck # tsc --noEmit
bun run extension # build the Chrome extension into extension/dist
bun x ultracite fix # lint + format
Pre-push, once, at the end of a change:
bun x ultracite fix && bun run typecheck && bun run build
Contribution rules#
- Spec before code. The component's four-layer spec in
docs/catalog/is reviewed and approved before implementation starts. A PR with code and no spec is closed. - No new token without a decision record. A new color, spacing value or duration needs a note in the relevant
foundations/doc saying which existing token fell short and why. - No
biome-ignore. Fix the code, or changebiome.jsoncwith a justification. - No committed
console.logordebugger. - No hardcoded credentials.
process.env, always. - Every PR runs the quality bar. The checklist is a PR template.
- A component is
stableonly after the next-day review. Merging doesn't make it stable.