Repository
Where the code lives, how it follows shadcn's monorepo setup, how a component file is built and the commands you run.
This page is for changing the design system itself. To use components in an app, see Using the system.
The monorepo
One npm workspace on GitLab (bridge-design-system) holds the package and this handbook. It follows shadcn’s monorepo setup: apps in apps/, the shared components in packages/ui. The handbook imports the package directly, so every example on this site is the real component.
Repository root
bridge-design-system/
apps/
handbook/ this site: the docs, /llms.txt and the /mcp server
packages/
ui/ @brdgapp/ui: components, hooks, lib, globals.css
DESIGN.md the frozen visual spec
PRODUCT.md who Bridge is for
.gitlab-ci.yml checks on every push; publishes to npm on a version tag
eslint.config.mjs lint rules for every workspace
package.json workspaces and root commandsWorkspace packages, read from each package.json
| Package | Path | Role |
|---|---|---|
@brdgapp/ui | packages/ui | Bridge's shared components, tokens and styles. Documented at apps-bridge-design-system-handbook.vercel.app. |
@bridge/handbook | apps/handbook | The Bridge handbook (apps-bridge-design-system-handbook.vercel.app): human docs, llms.txt and the read-only MCP. |
Bridge apps live in their own repos (Bridge OS is bridge-os/app) and install @brdgapp/ui like any other package. Nothing in packages/ui imports from an app.
Built on shadcn
Every component follows shadcn’s structure: Base UI underneath, the same file shape, a name on every part. Most were brought over from Bridge OS and rebuilt that way; Button started from shadcn itself. A brand-new component starts from shadcn’s Base UI version and is then rebuilt on Bridge’s tokens. shadcn reads components.json to know where files go and what to import, so the package carries one.
packages/ui/components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@brdgapp/ui/components",
"ui": "@brdgapp/ui/components",
"lib": "@brdgapp/ui/lib",
"hooks": "@brdgapp/ui/hooks",
"utils": "@brdgapp/ui/lib/utils"
}
}| Field | What it means for us |
|---|---|
style: base-nova | Components come in their Base UI version, not Radix. |
tailwind.css | New styles go into the one shared src/styles/globals.css. config is empty because Tailwind 4 needs no config file. |
iconLibrary: lucide | Icons are Lucide, the same as everywhere in Bridge. |
aliases | Point at the package’s public paths, so a pulled file imports @brdgapp/ui/lib/utils and @brdgapp/ui/components/button, never a relative path. |
In shadcn’s setup each app has its own components.json too, so running shadcn add in an app lands the file in packages/ui. This repo’s only app is the handbook, which never owns components, so components are pulled from the package itself with npm run ui-add.
Never run shadcn add inside an app such as Bridge OS. Apps only use the package; a new component is added to packages/ui, gets its handbook page, and then ships in a new version.
Inside packages/ui
The package’s exports map turns each folder into an import path. Counts are read from the files on every build.
| Folder | Files | Holds | Import as |
|---|---|---|---|
components | 37 | Atoms, molecules and organisms, one component family per file. | @brdgapp/ui/components/<file> |
hooks | 2 | Behavior without markup: useMockQuery for loading, empty and error states. | @brdgapp/ui/hooks/<file> |
lib | 5 | cn, the number and date formatters, the demo controls. | @brdgapp/ui/lib/<file> |
styles/globals.css | 1 | Every token, custom utility and base rule, 575 lines. | @brdgapp/ui/globals.css |
Some files hold a family (data-table.tsx has DataTable, its skeleton and its pager, and info-hint.tsx holds three tooltips). Its page covers every part the file exports.
Anatomy of a component file
Every file follows the same shape. Not every file needs all seven steps: Badge, the smallest, shows four of them (cva, data-slot, className last, named exports), and Button shows the other three.
- Client directive.
"use client"only when the file holds state, effects or event handlers. Button has it; Badge doesn’t. - Base UI primitive. Interactive parts wrap a primitive from
@base-ui/react/<part>, which brings roles, focus and keyboard behavior. Badge is a plainspan, so it has none. - cva variants.
cva(base, { variants, defaultVariants })holds the classes, using tokens only. The variants are exported so another file can borrow the look. - data-slot. Each rendered part names itself, such as
data-slot="badge". Parents style children through it, and the handbook’s anatomy drawings find parts by it. - render prop.
useRenderwithmergePropslets a component render as another element, such as a NextLink, keeping its classes and props. A link stays a link. - className last.
cn(variants({ … }), className)merges the caller’s classes after the component’s, so an override always wins. - Named exports at the bottom. One
export { Badge, badgeVariants }block closes the file, with types in their ownexport typeline. No default exports.
badge.tsx in full
Read from packages/ui/src/components/badge.tsx when this page builds.
import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@brdgapp/ui/lib/utils";
// The "Solid label" sticker from the Arch Design System: 8px, bold, uppercase. Ported from Bridge OS
// src/components/ui/badge.tsx. Rounded 4px, never a pill. For soft status and category labels use Tag.
// Two fixes over Bridge OS: solid-red fills with --danger-solid (white on --error fails AA at 4.41:1), and
// solid-dark's text uses --surface-primary so it stays readable in dark mode (white on light grey didn't).
const badgeVariants = cva(
"inline-flex items-center rounded-[4px] px-[7px] py-1 text-[8px] leading-[8px] font-bold tracking-[1px] uppercase",
{
variants: {
// Named `style` to match Bridge OS's API. It is not React's style prop: Badge takes no CSS styles.
style: {
outline: "border border-[var(--text-disabled)] text-[var(--text-muted)]",
alum: "border border-[var(--honey)] text-[var(--honey)]",
"solid-red": "bg-[var(--danger-solid)] text-white",
"solid-dark": "bg-[var(--text-secondary)] text-[var(--surface-primary)]",
},
},
defaultVariants: { style: "outline" },
},
);
type BadgeProps = VariantProps<typeof badgeVariants> & {
className?: string;
children: React.ReactNode;
};
function Badge({ style, className, children }: BadgeProps) {
return (
<span data-slot="badge" className={cn(badgeVariants({ style }), className)}>
{children}
</span>
);
}
export { Badge, badgeVariants };
export type { BadgeProps };
The render prop in button.tsx
A native button keeps Base UI’s button behavior. Given a render element, such as a link, Button renders that element instead.
// With `render` (for example a Next `Link`), the element keeps its own semantics: a link stays a
// link. It owns its content, so `icon` and `loading` don't apply.
const rendered = useRender({
render: render as useRender.RenderProp,
enabled: render !== undefined,
props: mergeProps<"a">({ className: classes, "data-slot": "button" } as React.ComponentProps<"a">, props as React.ComponentProps<"a">, { children }),
});
if (rendered) return rendered;Conventions
The small agreements that let files work together without reading each other.
| Convention | Rule |
|---|---|
data-slot | Every rendered part sets one, in kebab case, starting with its component (button-icon, stat-box-value). 104 distinct names exist today. |
| File names | kebab-case, one component family per file, named after the component: copy-field.tsx holds CopyField. |
| Sizes | Buttons are xs 28px, sm 32px (the default), md 40px and lg 48px. Icon buttons are sm 28px, md 32px (the default) and lg 36px. The names don’t match between the two, so pick by the height, not the name. Nothing clickable is smaller than 28px. |
type="button" | Base UI’s Button sets it. A hand-written <button> that doesn’t submit a form must say so. |
| Icons | From lucide-react, 14, 16, 18 or 24px, aria-hidden="true" beside text. An icon on its own needs an aria-label on its button. |
| Extra attributes | Components pass id, aria-*, data-* and handlers to their main element through ElementProps from @brdgapp/ui/lib/utils. |
| Exports | Named exports in one block at the end of the file. No file has a default export. |
| Imports between files | Use the public path, @brdgapp/ui/components/<file>, even inside the package. StatBox imports InfoHint that way. |
cn | Always from @brdgapp/ui/lib/utils, never clsx or the bare cn package, so Bridge’s own utilities merge correctly. |
| Timing | Color changes 150ms ease-out; switches and fades 200ms; the drawer slides in 300ms. globals.css turns motion off for people who ask for reduced motion. |
The docs site
The handbook is the Next.js app in apps/handbook. Its pages render real packages/ui code.
apps/handbook
src/
app/
page.tsx the overview
[...slug]/page.tsx every other page, driven by the registry
llms.txt, llms-full.txt the index and every component's guidance, for agents
mcp/route.ts the read-only MCP server
components/design/
registry/ pages.ts (every page), rules.ts (named rules), recipe.ts,
loaders.ts and demo-data-pages.ts (generated), adoption.ts
content/ one folder per page: doc.tsx, demos.tsx, guidance.ts or guide.ts
kit/ page building blocks: sections, examples, anatomy, states
site/ sidebar, header, breadcrumbs, on this page
agent/ the MCP tools, the code checker, the llms.txt text
sample/ network.ts, the sample network every example uses
scripts/ design-sync, adoption, and the token, checker and MCP testsDocs site counts
| What | Count |
|---|---|
| Pages in the registry | 96 |
Page bodies (doc.tsx) | 96 |
Demo files (demos.tsx) | 74 |
Component guidance files (guidance.ts) | 37 |
Pattern guides (guide.ts) | 19 |
Add a page by adding an entry to registry/pages.ts, then run npm run design:sync. It regenerates loaders.ts and demo-data-pages.ts and writes a stub doc.tsx, so a new entry never breaks the build.
Tooling
Run everything from the repository root. The Runs column is read from the package.json files.
| Command | Runs | Use it to |
|---|---|---|
npm run dev | next dev --port 3100 | Start the handbook at localhost:3100. |
npm run build | npm run design:check && npm run test && next build | Check the registry, run the token and checker tests, then build the handbook. GitLab runs this on every push. |
npm run check-types | npm run check-types --workspaces --if-present | Type-check every workspace. |
npm run lint | eslint . | Check the whole repo with ESLint. |
npm run design:sync | node scripts/design-sync.ts | Regenerate the generated registry files and write stubs after editing the registry. |
npm run ui-add -- <name> | node scripts/ui-add.mjs | Pull a shadcn component (Base UI version) into packages/ui and point its cn import at @brdgapp/ui/lib/utils. |
npm run build:ui | node scripts/build.mjs | Build the package into packages/ui/dist, the folder that goes to npm. |
npm run design:adoption -w @bridge/handbook -- --app <name> --repo <path> | node scripts/adoption.ts | Count how much of an app’s main branch uses the package. |
git tag v1.2.0 && git push origin v1.2.0 | GitLab CI | Release: GitLab runs every check, then publishes that version to npm. |
- A pulled component arrives in shadcn’s style. Before anyone uses it, rebuild it on Bridge tokens, add
data-slotnames and write its page (Component lifecycle). - Never edit
loaders.tsordemo-data-pages.tsby hand;design:syncowns them and the build fails if they drift.