ADR 0010. Shared code ships inside every registry item
One substrate module, appended to every component's files list with its real content, so the same import path resolves in this repository and in a consumer's.
Status
Accepted. 2026-09-03. Verified end to end against shadcn 4.20.0 rather than
reasoned about: a registry item served from this repository installed into a
throwaway app outside it, the substrate landed at lib/opsinjs.ts and
lib/status.ts, a registryDependencies entry pulled its component in
alongside, and tsc --noEmit on the result exited 0.
Context
ADR 0002 makes the unit of distribution
a file of source text rather than a package. That works cleanly for a component
that imports nothing but React. It does not work at all for sixty components that
have to agree with each other about what watch means.
What they have to share is small, and none of it is decoration: the four clinical
levels and the words for them, the six health categories, the icon name per
level, the guard functions, the axis-conflict check, and the shapes that appear
in more than one interface, which are a reference range, a trend point and a
material rung. It already exists, in lib/status.ts, and it is already the thing
the documentation site, the machine routes and the token pipeline all read.
The failure mode of getting this wrong is unusually quiet. A component that
imports @/lib/opsinjs compiles here, because apps/www/tsconfig.json maps
@/* onto ./*. In a consumer's repository the same import resolves only if a
file exists at their @/lib/opsinjs. And shadcn's installer skips, without a
message, any entry in an item's files list that has no content. A registry
item that names a file it does not carry therefore installs cleanly, reports
success, and leaves the consumer with a component that cannot compile and no
error text to search for.
The alternative to sharing is duplication, and duplication has a specific cost
here rather than a general one. Sixty private copies of the status vocabulary are
sixty places somebody can add a fifth level, rename attention, or quietly
reintroduce a word from the banned list. Every one of those copies would
typecheck.
Decision
One substrate module at apps/www/lib/opsinjs.ts, and it ships inside every
component's registry item.
- The module re-exports
lib/status.tsand never re-declares it. The vocabulary is declared once, in the file the scripts and the site already read. A reference range, a trend point, a material rung, the warning channel and the example-data literal of ADR 0012 are needed only by components, and the substrate module declares them itself, once. - A component imports from
@/lib/opsinjs, from@/lib/utils, from@/registry/base-lyra/ui/<id>when it composes another opsinjs component, and from nothing else in this repository.@/lib/utilsis safe becauseshadcn initwriteslib/utils.tsinto every consumer before anything is installed. The third one needs its own paragraph, below, because it is the specifier a reasonable person would not guess. scripts/build-registry.mtsappends the substrate files to every component item'sfileslist, each withtype: "registry:lib"and realcontent. shadcn resolvesregistry:libagainst thealiases.libentry incomponents.json, which is@/libhere and@/libin a default consumer, so@/lib/opsinjsresolves identically in both trees.- Nothing is rewritten on the way out. The text a consumer receives is
byte-for-byte the text
<ComponentSource>displays and the text this repository reviews.
The substrate is library code. Component-to-component composition is a different
mechanism with two halves, and both are required. registryDependencies on the
catalogue row is how result-card names status-pill and value, which is what
makes shadcn add fetch and write them; it never inlines a copy of either. But a
catalogue row does not compile anything. The importing FILE still has to name its
dependency, and there is exactly one specifier that works in both trees:
import { StatusPill } from "@/registry/base-lyra/ui/status-pill"Neither obvious alternative survives. @/components/ui/status-pill resolves here
to apps/www/components/ui/, which is the lyra documentation chrome.
components/ui/button.tsx already lives there and is emphatically not the
opsinjs Button, so the collision is not hypothetical.
@/registry/bases/base/status-pill resolves here but shadcn rewrites it to
@/components/base/status-pill, a path nothing writes to, so the install reports
success and the consumer's build fails afterwards. That is the worst of the
available failures.
The form above resolves here because apps/www/tsconfig.json maps
@/registry/base-lyra/ui/* to ./registry/bases/base/*, and it resolves there
because shadcn's import transform matches /^@\/registry\/(.+)\/ui/ and
rewrites it to the consumer's aliases.ui. base-lyra is the style segment
shadcn's own convention expects in that position; it is not a directory on disk
and does not need to be.
Consequences
- Every item is self-sufficient, so installation order does not matter and
there is no item a consumer has to know to install first.
shadcn add @opsinjs/status-pillinto an empty project produces something that compiles. - A filename collision is possible, and a prompt is the only thing standing in
the way.
lib/status.tsis a plausible name in somebody's repository. Thetargetis set explicitly so shadcn resolves a real path and prompts before overwriting, but a consumer who accepts the prompt without reading it loses their file. That is a permanent cost of copy-in distribution, it is the same cost ADR 0002 accepted for components, and the mitigation is documentation rather than machinery. See Upgrades and diffs. - The substrate must never re-declare what it re-exports. Two live
declarations of
ClinicalStatusis exactly the drift this decision exists to prevent, and it would typecheck in both trees while meaning two different things in each. - The substrate inherits the erasable-syntax constraint.
scripts/*.mtsload it under plain Node 24 type stripping, so noenum, no parameter properties, nonamespace, no JSX, and every relative import needs an explicit.tsextension. A component file has no such constraint; the substrate does. - Every item's payload grows, and a substrate change is a sixty-item diff.
/r/registry.jsonand each/r/<name>.jsonnow carry the shared text, and editing one line of the substrate changes every published item. That is visible and reviewable, which is the intended trade against invisible drift. - One install writes the substrate once, however many components it pulls in.
The installer merges the whole resolved tree and dedupes by resolved target
path before writing, so a component that names two others through
registryDependenciesdoes not produce three copies of the same file. Across separate installs the file is simply written again over an identical one.
Alternatives considered
A workspace npm package, @opsinjs/core. pnpm-workspace.yaml lists
packages/*, and that directory exists on disk as a reserved and empty
placeholder, so the workspace glob is valid and no package ships from it yet.
Rejected on the deeper ground rather than on the state of that directory: a
package cannot travel through /r/*.json, because a registry item carries file
contents
and not a dependency resolver. Adopting one would mean a consumer runs an install
command and adds an npm dependency, and the npm half reintroduces exactly the
version coupling that ADR 0002 exists
to avoid. That record draws a split where the registry is for components and the
packages are for functions, and it puts a vocabulary of unions and words firmly
on the registry side.
The substrate as its own registry item, named in every registryDependencies.
The mechanism is real: shadcn walks registryDependencies and merges the
resulting tree. Rejected because the item route is catalogue-gated. An id with no
catalogue row answers unknown-item with a 404 and a did-you-mean list. So
making it resolve means a sixty-first catalogue row, and a catalogue row is not a
cheap thing: it needs a documentation page or the build fails, an entry in
components/meta.json, a slot in the global alias namespace, and it appears on
the component roster and in /r/index.json alongside sixty components as though
it were a sixty-first. Paying all of that for a file of type unions is
the wrong shape.
Rewrite imports at generation time, emitting a relative ./status into the
shipped copy. Rejected: the emitted file would then differ from the reviewed
file. <ComponentSource> would show one thing and the consumer would receive
another, so the source a reviewer approved would not be the source anybody runs.
Duplicate the small parts into each component. Rejected above, and worth restating as the reason this record exists at all: the cost is not the bytes, it is that a vocabulary with sixty copies is a vocabulary with no owner.
Revisiting this
Revisit when the substrate grows past what a consumer would sit down and read, or when it acquires something algorithmic rather than declarative. The line is the one ADR 0002 already drew: deriving a ramp, converting between unit systems and scoring a questionnaire are all computing, and a module that computes belongs in a package that is imported, not in a file that is copied. A module that declares belongs here.
Last read through against the system on 2026-09-20. Due for review every 12 months; expiry is reported by pnpm run check:freshness.
ADR 0009. The default export is a demo, not the component
Every registry file exports a named component with its real required props and a separate zero-argument demo as its default export.
ADR 0011. Anatomy parts are structure, not a public API
The part tree in every specification is the DOM contract expressed as data-slot, not exported compound components, and the attribute vocabulary is four names.