Skip to content

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's migrate-radix-to-base skill 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:

  1. Semantic versioning on the registry, surfaced per item, with the version stamped in a header comment in every generated file.
  2. A changelog per item, not just per release, so npx @lopifi/diff balance-card can show what changed since the version in your repo.
  3. A codemod for breaking token renames. Token changes are the only breaking change that can be mechanized, and they're the most common.
  4. Additive-only within a major. New props, new variants, new items. Never a changed default, never a removed prop.
  5. The theme item 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>. Duplicate title or canonical tags are the failure mode here and they fail silently.
  • title, description, canonical and og:url must agree. A page that canonicalizes to /docs/money while its og:url says /components/money has 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. SoftwareSourceCode on 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.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.