velobits-ui
The VeloBits design system. One token and component layer for every VeloBits surface: the marketing site, the editor app, the dashboard app, and the shared Keycloak login theme.
Implementation notes and the full plan live at workspace
docs/VelobitsUI/, not in this repo, per the standing convention.
One build, one artefact
npm run build produces apps/docs/out/, and that folder is the whole
deployable — the documentation and the registry the shadcn CLI fetches, at one
origin. Deploy it to ui.velobits.dev and both halves ship together.
Steps 2 and 3 run from the docs app's own build script, and turbo's
dependsOn: ["^build"] guarantees the packages are built first — which step 2
needs, since it imports @velobitsio/tokens.
Deploying to Vercel
vercel.json at the repo root already carries the whole configuration, so the
project needs no settings in the dashboard:
Then add ui.velobits.dev under Settings → Domains.
Two of those are load-bearing:
Root Directory must stay at the repo root. Pointing it at apps/docs makes
Vercel install and build only that workspace, so the three packages never build
and scripts/build-registry.ts dies importing @velobitsio/tokens. Turbo's
dependsOn: ["^build"] is what orders them, and it only runs from the root.
Framework Preset is Other, not Next.js. apps/docs is a Next app, but it
builds with output: 'export' — Vercel's Next builder is for server output and
would also look for a next.config at the repo root, where there is none. What
this repo produces is a directory of files, which is what Vercel serves best.
Other hosts
apps/docs/public/_headers is copied into out/ and read directly by
Cloudflare Pages and Netlify — nothing else to do. (Vercel ignores it,
which is why the rules are restated in vercel.json.) For anything else, the two
that matter are CORS on /r/* — for browser-based consumers; the CLI is a Node
process and never needed it — and no long-lived cache on it, since a component's
source changes under a stable URL:
REGISTRY_BASE_URL overrides the origin baked into registryDependencies — set
it to point a build at a local server and verify an install end to end:
That last tsc is the check worth keeping. The CLI half is copy-and-paste, so it
can install cleanly and still not compile — which is exactly what it did until the
imports were rewritten. See scripts/registry-layout.ts.
Where the CLI puts things
Everything lands in one flat folder inside the consumer's ui alias, with cn
at their utils module:
The prefixes are placeholders (@ui/, @lib/) resolved against the consumer's
components.json; only velobits/ is fixed. Targets and the import rewrites both
come from scripts/registry-layout.ts, which build-registry.ts stamps and
build-docs-data.ts reads for the docs — so the path in the documentation cannot
disagree with the path the CLI uses.
Layout
Adding a component means touching four lists — registry/registry.ts,
packages/ui/tsup.config.ts, the exports map in packages/ui/package.json,
and the barrel — and packages/ui/test/registry-parity.test.ts fails if you miss
one. It then gets a documentation page automatically; apps/docs/lib/docs-nav.ts
only decides which sidebar heading it appears under, and the build fails naming
any item that file does not place.
Two distributions, and which one to use is not taste
Adding a component means touching four lists, and
packages/ui/test/registry-parity.test.ts fails if you miss one:
registry/registry.ts, packages/ui/tsup.config.ts, the exports map in
packages/ui/package.json, and the barrel registry/velobits/index.ts.
Consuming it
The storage key is required and deliberately not defaulted: the editor app persists
to fmx_theme_mode and the dashboard app to tf.theme, both with live user data, so a
default would silently orphan one app's preferences.
Three things that will bite you
--primary is not a text colour. #007ACC measures 3.90:1 on the cream
page — fine as a fill behind white text (4.51:1), a WCAG failure as text. Links
use text-link. No Button variant paints it as text, and a test enforces that.
Lime is asymmetric. bg-brand + text-on-brand (charcoal) is 10.89:1 and is
the only sanctioned pairing — white on lime is 1.31:1. Lime as text is 13.24:1
in dark mode and 1.13:1 in light, so --accent-text is lime in dark and plum in
light. A lime fill also cannot be a lone graphical indicator in light mode.
Glass is the overlay tier only. Dialog, Sheet, Popover, DropdownMenu, Toast, CommandPalette, sticky headers. Never page backgrounds, table rows, anything inside a scroll container, or nested.
The gates
Gotchas encoded in this repo
- The base border reset must say
var(--border), nevervar(--color-border). A@themevar is emitted as a real:rootdeclaration, so it resolves to the light value and inherits everywhere —body.darknever reaches it. 'use client'needs two tsup builds. esbuild strips directives from bundled modules, and tsup'streeshake(a Rollup pass) strips thebannerthat re-adds them.lib/theme.tsmust stay directive-free so a Server Component can callthemeInitScript().- Custom scale values need registering with
tailwind-merge. It cannot resolve a conflict it does not recognise, sorounded-pillleftrounded-mdin place and the winner was decided by stylesheet order. - Icons need
/*#__PURE__*/.sideEffects: falsedescribes the module, not each initialiser; without the annotation, importing one icon cost 3.4 kB of the set's 3.92 kB. - Renaming a
@themetoken needs a dev-server restart. Tailwind's candidate cache lies through HMR. z-*andduration-*are@utilityrules, not theme values. Tailwind v4 has no--z-index-*or--duration-*namespace, so declaring them in@themeemits a variable and no utility —z-tooltipwould silently do nothing.
Publishing
GitHub Packages, private, @velobits/*. Every published change needs a
changeset (npm run changeset). @velobitsio/tokens versions independently so a
palette tweak does not force a component release.
Reads require auth even for consumers. In Docker use a BuildKit secret
(RUN --mount=type=secret,id=npmrc), never an ARG — an ARG is recorded in
the image history.
When @velobitsio/ui gets a new version, the editor app's Federation
requiredVersion pins in apps/shell, apps/editor-remote and
apps/analytics-remote must move in lockstep. Overshooting the pin gives
does not satisfy warnings and then a fatal
does not provide an export named 'default' — a blank page, not a build error.