Registry & distribution
Lopifi distributes through the shadcn registry protocol. Components are copied into the consumer's repo, and from then on the consumer owns the code.
That model suits a finance library because finance teams always end up changing the rounding policy, the fiscal calendar, the categorization rules and the compliance copy. You can't edit any of that inside a black-box npm package.
Install shape#
npx shadcn@latest add @lopifi/theme # always first — tokens + Tailwind preset
npx shadcn@latest add @lopifi/balance-card
npx shadcn@latest add @lopifi/profit-and-loss
npx shadcn@latest add @lopifi/swap-widget
Consumer config:
// components.json
{
"registries": {
"@lopifi": "https://lopifi.com/r/{name}.json"
}
}
registryDependencies resolve automatically. Every relative import in a source file becomes one: installing @lopifi/money pulls @lopifi/theme, @lopifi/cn, @lopifi/format-money, @lopifi/money.types and @lopifi/tooltip, and each of those is its own item. npm packages a file imports (class-variance-authority, @tanstack/react-virtual) land in dependencies. None of this is written by hand. scripts/build-registry.mjs derives it from the imports, and fails the build if it can't rewrite a file's relative import.
registry.json#
The shape scripts/build-registry.mjs emits, abridged (public/r/registry.json is the real one; each /r/{name}.json adds the file content):
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "lopifi",
"homepage": "https://lopifi.com",
"license": "MIT",
"items": [
{
"name": "theme",
"type": "registry:theme",
"title": "Lopifi theme",
"registryDependencies": [],
"files": [
{ "path": "styles/ledger-tokens.css", "target": "styles/ledger-tokens.css", "type": "registry:style" },
{ "path": "ledger/preset.ts", "target": "ledger/preset.ts", "type": "registry:file" },
{ "path": "styles/ledger-theme.css", "target": "styles/ledger-theme.css", "type": "registry:file" }
]
},
{
"name": "cn",
"type": "registry:lib",
"dependencies": ["clsx", "tailwind-merge"],
"registryDependencies": [],
"files": [{ "path": "lib/ledger/cn.ts", "target": "lib/ledger/cn.ts", "type": "registry:lib" }]
},
{
"name": "money",
"type": "registry:ui",
"title": "Money",
"dependencies": ["class-variance-authority"],
"registryDependencies": ["@lopifi/theme", "@lopifi/cn", "@lopifi/format-money", "@lopifi/money.types", "@lopifi/tooltip"],
"files": [{ "path": "components/ledger/money.tsx", "target": "components/ledger/money.tsx", "type": "registry:ui" }]
}
]
}
One file per item. A catalog row that lives in a multi-component module (invoice-register in ar-documents.tsx) still installs by its own name; its item carries the module's file, so installing two rows from one module writes the same file twice and the CLI skips the second.
Item types#
| Type | Use |
|---|---|
registry:theme |
Token layer. Installed first, once. |
registry:lib |
Pure utilities — formatters, allocation, date helpers |
registry:hook |
use-density, use-live-value, use-column-priority |
registry:ui |
Primitives — Money, Delta, Figure, StatusPill |
registry:component |
Composed but single-purpose — TransactionRow, AmountInput |
registry:block |
Full surfaces — ProfitAndLoss, SwapWidget, DashboardShell |
registry:page |
Complete routes — payments-page, close-dashboard |
Dependency graph#
Strictly layered. Dependencies point downward only. A cycle would mean an item can't be installed on its own.
Layer 0 theme tokens, CSS vars, Tailwind preset
↑
Layer 1 lib format-money · allocate · fiscal-period · money.types
↑
Layer 2 hooks use-density · use-live-value · use-reduced-motion
↑
Layer 3 primitives popover · dialog · dropdown-menu · checkbox · radio-group · tooltip
(src/ledger/primitives/ — the only files that import Base UI)
↑
Layer 4 components Money · Delta · Figure · StatusPill · TransactionRow · AmountInput
↑
Layer 5 blocks TransactionTable · ProfitAndLoss · SwapWidget · BalanceCard
↑
Layer 6 pages Dashboard · Payments · Close
The single-purpose display components (Money, Delta, Figure) still publish as registry:ui; "primitives" in the graph means the wrapper layer under them, the one seam between Lopifi and the accessible-primitive library.
Every item is installable on its own. @lopifi/money pulls only what sits below it — theme, cn, format-money, money.types and tooltip — and no table, chart or form. A team can adopt the money primitive and nothing else.
The primitive dependency#
Layers 3–6 sit on accessible primitives we don't own, and those primitives are in the middle of a migration. A copy-paste registry has to handle that before a consumer runs into it.
Base UI is the destination; Radix is what most consumers have installed today. shadcn's own primitives are mid-migration, so @lopifi/transaction-table may land on either, depending on when the consumer ran shadcn add dropdown-menu. Our position:
- Lopifi components never import a primitive directly. They import a thin wrapper in
src/ledger/primitives/— one file per primitive, named the way shadcn names it (popover.tsxexportingPopover,PopoverTrigger,PopoverPortal,PopoverPositioner,PopoverPopup;dialog.tsx,dropdown-menu.tsx,checkbox.tsx,radio-group.tsx,tooltip.tsx,hover-card.tsx). Those files are the only ones that import@base-ui-components/react. Each ships as its own item,@lopifi/primitive-{name}, installed tocomponents/ledger/primitives/{name}.tsx, and every component that uses it lists it inregistryDependencies—scripts/build-registry.mjsderives the edge from the../primitives/*import like any other. A consumer who already has shadcn wrappers can re-point these files at@/components/ui/*, and whichever primitive that resolves to is the one that gets used. - Nothing in our code touches primitive-specific internals, with one exception: the transform-origin variable, which is tokenized for both.
- Never two primitive systems in one interaction surface. A Radix dialog containing a Base UI select fights over focus. The rule is stated in accessibility, and the import rule above enforces it.
- When a consumer migrates, only the wrappers change. If a primitive migration requires editing any
@lopifi/*file other than aprimitive-*wrapper, we imported something we shouldn't have.
The scratch-app step in repo hosting runs on both primitive sets. Run it on only one and nothing enforces this rule.
The theme item#
Installed first. Of everything in the registry, this is the one item not to fork casually.
/* app/globals.css — installed by @lopifi/theme */
@layer base {
:root {
/* color */
--canvas: oklch(0.985 0.002 265);
--surface: oklch(1 0 0);
/* … full set from foundations/color.md … */
/* space, radius, type, motion, z — all from foundations/ */
}
[data-theme="dark"] { /* … */ }
[data-value-palette="signal"] { /* … */ }
}
Plus a Tailwind preset so the tokens are reachable as utilities:
// tailwind-ledger-preset.ts
export default {
theme: {
extend: {
colors: { canvas: "var(--canvas)", surface: "var(--surface)", /* … */ },
borderRadius: { xl: "24px", /* … */ },
transitionTimingFunction: { out: "cubic-bezier(0.23,1,0.32,1)", /* … */ },
},
},
};
Tailwind v4 consumers get the same scales as @theme variables in CSS, with no JS config at all. @lopifi/theme ships both: ledger/preset.ts for v3 and styles/ledger-theme.css for v4 (source: src/ledger/theme-v4.css). On v4, import it after Tailwind and the tokens:
@import "tailwindcss";
@import "./ledger-tokens.css";
@import "./ledger-theme.css";
The v4 file is maintained by hand beside the preset; scripts/build-registry.mjs fails the build if a color token the preset maps is missing from it. The global button press ships in styles/ledger-tokens.css ("Press, globally"): every button scales to 0.97 on :active (0.99 for wide rows and data-press="soft", none for data-press="none", options, tabs and menu items), with nothing under prefers-reduced-motion: reduce. Its own transition-property: scale lands only on a button that names no transition-* utility, at zero specificity, so it never beats a utility — including on v4, where utilities sit in a cascade layer and the tokens file does not. One known gap remains: v4's transition-colors / -opacity / -shadow / -transform property lists are fixed by Tailwind and do not include scale, so on v4 a button carrying one of those utilities presses without easing (the v3 preset adds scale to each list).
Multi-component modules#
Some catalog components share a file — controls.tsx holds thirteen, ar-documents.tsx three. Each still installs by its own name: @lopifi/invoice-register is an item carrying components/ledger/ar-documents.tsx and that module's dependencies. Installing two components from one module writes the same, identical file twice, which the CLI skips.
Versioning#
Copy-paste distribution has no upgrade path. You give that up in exchange for owning the code. What exists today: registry.json carries one registry-wide version (the package version) and "license": "MIT", and each item carries meta.license. Items carry no version of their own yet.
- Planned — per-item semantic versioning, with the version stamped in a header comment in every generated file. Not built: items are unversioned today.
- Planned — a changelog per item, not just per release, and a
npx @lopifi/diff balance-cardcommand to show what changed since the version in your repo. Neither the changelog nor the command exists yet. - Planned — a codemod for breaking token renames. Token changes are the only breaking change that can be mechanized, and they're the most common. Not built.
- Policy — additive-only within a major. New props, new variants, new items. Never a changed default, never a removed prop.
- Policy — the
themeitem is the stable contract. Components change; token names don't, within a major.
Docs site#
Sonner's lesson applies: build a great documentation site and let people touch the thing before they adopt it. The docs site is a milestone with its own schedule, because it's how a component library gets used.
- Every component is live and interactive, with the props panel wired to working controls.
- Every example is copy-paste-ready, with the install command next to it.
- Every example includes the edge cases: the negative, the null, the JPY, the very long label. Those are what set Lopifi apart, so the examples show them.
- A theme toggle, a density toggle, a signal-palette toggle and a reduced-motion toggle on every example, so users can check the system's claims for themselves.
- Motion specs you can watch: a slow-motion toggle on every animated example.
- The design docs ship with the site as well as the repo.
- Built with the Next.js App Router, rendering the Markdown specs with
marked, deployed on Cloudflare Workers through OpenNext. The site is also the visual regression target.
Metadata#
Every component page is a shareable link, and the site is the only way people find the product, so metadata ships with the page from the start. Audited against ibelick/ui-skills (fixing-metadata).
- One source per page. Next.js
generateMetadata, never a competing<head>. Duplicatetitleorcanonicaltags break without any error. title,description,canonicalandog:urlmust agree. A page that canonicalizes to/docs/moneywhile itsog:urlsays/components/moneytells the search index and the share preview two different things.- Open Graph images are absolute,
summary_large_image, and generated per component: the component rendered on its own surface, with a negative in it. The preview shows a component instead of a logo. - Preview deployments are
noindex. Each one is a complete copy of the docs, and left indexable it competes with the production site for the same searches. - No JSON-LD we can't back.
SoftwareSourceCodeon a component page is accurate. Ratings would be invented.
Scoped to the site: nothing in the registry emits metadata, and no @lopifi/* component ever sets a page title.
Repo hosting#
The component code is open source under the MIT license and reaches consumers through the registry: npx shadcn add copies the source into their repo. The registry declares it ("license": "MIT" in registry.json, meta.license on every item). registry.json at the root, built artifacts served from /r/{name}.json on the docs domain, generated by bun run gen (scripts/build-registry.mjs).
Today .github/workflows/ci.yml runs generate → lint → typecheck → test → build. The target registry pipeline adds the scratch-app install:
.github/workflows/registry.yml
→ shadcn build
→ validate every item resolves
→ install every item into a scratch Next.js app — once on Radix, once on Base UI
→ typecheck both scratch apps
→ deploy
The third step does the most work: CI installs every registry item into a clean app and typechecks it. The most common registry bug is a reference to a file that doesn't exist, and nobody sees it until a user hits it.