---
title: "Registry & distribution"
description: "Lopifi distributes through the shadcn registry protocol. Components are copied into the consumer's repo, and from then on the consumer owns the code."
canonical: "https://lopifi.com/docs/architecture/registry-and-distribution"
---

# 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.

Source: https://lopifi.com/docs/architecture/registry-and-distribution
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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

```bash
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:
```json
// 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`):

```json
{
  "$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.tsx` exporting `Popover`, `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 to `components/ledger/primitives/{name}.tsx`, and every component that uses it lists it in `registryDependencies` — `scripts/build-registry.mjs` derives 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](https://lopifi.com/md/docs/motion/tokens).
- **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](https://lopifi.com/md/docs/craft/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 a `primitive-*` wrapper, we imported something we shouldn't have.**

The scratch-app step in [repo hosting](https://lopifi.com/md/docs/architecture/registry-and-distribution#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.

```css
/* 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:

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

```css
@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.

1. **Planned — per-item semantic versioning**, with the version stamped in a header comment in every generated file. Not built: items are unversioned today.
2. **Planned — a changelog per item**, not just per release, and a `npx @lopifi/diff balance-card` command to show what changed since the version in your repo. Neither the changelog nor the command exists yet.
3. **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.
4. **Policy — additive-only within a major.** New props, new variants, new items. Never a changed default, never a removed prop.
5. **Policy — the `theme` item 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>`. Duplicate `title` or `canonical` tags break without any error.
- **`title`, `description`, `canonical` and `og:url` must agree.** A page that canonicalizes to `/docs/money` while its `og:url` says `/components/money` tells 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.** `SoftwareSourceCode` on 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.
