Registry & distribution
Lopifi distributes through the shadcn registry protocol. Components are copied into the consumer's repo; they own the code from that moment.
Don't reinvent this. shadcn's model is correct for a domain library specifically because finance teams must modify — rounding policy, fiscal calendar, categorization rules, compliance copy. A black-box npm package is the wrong shape for something that has to be edited.
Install shape
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, including shadcn's own primitives — installing @lopifi/transaction-table pulls @lopifi/money, @lopifi/status-pill, and shadcn's table, checkbox, dropdown-menu.
registry.json
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "ledger",
"homepage": "https://lopifi.com",
"items": [
{
"name": "theme",
"type": "registry:theme",
"title": "Lopifi theme",
"description": "Design tokens: color, spacing, radius, motion, typography.",
"cssVars": { "light": {}, "dark": {} },
"css": {}
},
{
"name": "money",
"type": "registry:component",
"title": "Money",
"description": "Renders a monetary value with correct formatting, sign, and alignment.",
"dependencies": [],
"registryDependencies": ["@lopifi/theme"],
"files": [
{ "path": "components/ledger/money.tsx", "type": "registry:component" },
{ "path": "lib/ledger/format-money.ts", "type": "registry:lib" },
{ "path": "lib/ledger/money.types.ts", "type": "registry:lib" }
]
},
{
"name": "balance-card",
"type": "registry:block",
"title": "Balance card",
"description": "Account balance with delta and optional sparkline.",
"registryDependencies": ["@lopifi/money", "@lopifi/delta", "@lopifi/sparkline", "card"],
"files": [{ "path": "components/ledger/balance-card.tsx", "type": "registry:component" }]
}
]
}
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 here means an item can't be installed independently.
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 Money · Delta · Figure · StatusPill · Sparkline · Rule
↑
Layer 4 components TransactionRow · AmountInput · StatementRow · RateLine
↑
Layer 5 blocks TransactionTable · ProfitAndLoss · SwapWidget · BalanceCard
↑
Layer 6 pages Dashboard · Payments · Close
Every item is installable on its own. @lopifi/money pulls only theme. This is what makes adoption incremental — a team can take just the money primitive and nothing else.
The primitive dependency
Layers 3–6 sit on accessible primitives we don't own, and that dependency currently has a migration running through it. A copy-paste registry needs an answer before a consumer hits 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 items never import a primitive directly. They import the consumer's shadcn wrapper (
@/components/ui/dialog), so whichever primitive that wrapper resolves to is the one that gets used — and we never pin a version of somebody else's dependency inside a file the consumer owns. - 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. Stated in accessibility; enforced here by the import rule above.
- When a consumer migrates,
shadcn-ui/ui'smigrate-radix-to-baseskill rewrites the wrappers and our items need no change at all. That is the test of whether the rule is holding: if a Base UI migration requires editing a@lopifi/*file, we imported something we shouldn't have.
The scratch-app step in repo hosting runs on both primitive sets. If it only ever runs on one, this rule is documentation rather than a constraint.
The theme item
Installed first, and it's the one thing that must not be forked 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/03-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 @theme in CSS with no JS config at all — preferred, and the registry ships both.
Versioning
Copy-paste distribution means there's no upgrade path — which is the trade for ownership. Mitigations:
- Semantic versioning on the registry, surfaced per item, with the version stamped in a header comment in every generated file.
- A changelog per item, not just per release, so
npx @lopifi/diff balance-cardcan show what changed since the version in your repo. - A codemod for breaking token renames. Token changes are the only breaking change that can be mechanized, and they're the most common.
- Additive-only within a major. New props, new variants, new items. Never a changed default, never a removed prop.
- The
themeitem is the stable contract. Components change; token names don't, within a major.
Docs site
Per the Sonner principle: build a great documentation site — let people touch the thing before they adopt it. This is a milestone, not an afterthought, and it's how a component library gets used.
- Every component is live and interactive, with the props panel wired to real 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. That's the differentiation, so show it rather than describing it.
- A theme toggle, a density toggle, a signal-palette toggle, and a reduced-motion toggle on every example. Users should be able to see the system's claims hold.
- Motion specs are demonstrable — a slow-motion toggle on every animated example, so the craft is inspectable.
- The design docs ship with the site, not just in the repo. The guidelines are part of the product.
- Built with the Next.js App Router, rendering the Markdown specs with
marked, deployed on Vercel. The site is also the visual regression target.
Metadata
Every component page is a shareable link, and the site is the product's entire discovery surface — so metadata ships with the page rather than being retrofitted onto it. Audited against ibelick/ui-skills (fixing-metadata).
- One source per page. Next.js
generateMetadata, never a competing<head>. Duplicatetitleorcanonicaltags are the failure mode here and they fail silently. title,description,canonicalandog:urlmust agree. A page that canonicalizes to/docs/moneywhile itsog:urlsays/components/moneyhas told the index and the share preview two different things about itself.- Open Graph images are absolute,
summary_large_image, and generated per component — the component rendered on its own surface, with a negative in it. This is a component library; the preview should show a component, not a logo. - Preview deployments are
noindex. Every Vercel preview is a complete duplicate of the docs; left indexable, they compete with the real site for its own terms. - No JSON-LD we can't back.
SoftwareSourceCodeon a component page is honest. Invented ratings are not.
Scoped to the site: nothing in the registry emits metadata, and no @lopifi/* component ever sets a page title.
Repo hosting
Private GitHub repo; the project is not open source. Components reach consumers only through the registry, under the site's terms of use. 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
That third step is the one that matters: CI installs every registry item into a clean app and typechecks it. A registry that references a file that doesn't exist is the most common failure mode, and it's silent until a user hits it.