-
Notifications
You must be signed in to change notification settings - Fork 1
Component Library
This page covers @openrunic/ui: the 22 components it exports, how they consume design tokens, and what adding a new one involves. It is for anyone building a screen or extending the library.
For the visual language the library implements, see Design system.
packages/ui/
├── src/
│ ├── index.ts the barrel, alphabetical
│ ├── types.ts the shared prop vocabulary
│ ├── components/
│ │ ├── _index.css one @import per component, alphabetical
│ │ └── <Name>/
│ │ ├── <Name>.tsx
│ │ ├── <Name>.css
│ │ ├── <Name>.stories.tsx
│ │ ├── <Name>.test.tsx
│ │ └── index.ts
│ ├── lib/ cx, lucide resolution, useFieldId
│ └── styles/
│ ├── index.css the fixed import order
│ └── tokens/ fonts, colors, typography, spacing,
│ radius, elevation, motion, base
└── .storybook/
Published shape: ESM, sideEffects: ["**/*.css"], two export entries (. and ./styles.css), React 19 as a peer dependency, and lucide-react as the only runtime dependency.
Actions
| Component | Notes |
|---|---|
Button |
Five variants: primary, secondary, ghost, inverse, danger. Three sizes. Renders an anchor when href is set, a button otherwise. |
IconButton |
Square, icon-only. label is required and becomes both the accessible name and the tooltip. |
Forms
| Component | Notes |
|---|---|
Input |
Label, hint, error, leading icon, suffix, and a monospace option for identifiers. |
Select |
A native select in brand chrome. Accepts strings or option objects. |
Checkbox |
Multi-select or consent. The real input stays in the accessibility tree. |
Radio |
One choice of two to five, grouped by shared name, using native arrow-key roving. |
Switch |
An immediate-effect setting. A button with role="switch", not a checkbox, because it takes effect on toggle rather than on save. |
Surfaces
| Component | Notes |
|---|---|
Card |
A raised section with four surface tones and a configurable heading level, labelled by its own title. |
Modal |
A centred dialog over an espresso scrim, with focus trap, Escape close, and focus restoration. Supports the alert-dialog role for required decisions. |
Data
| Component | Notes |
|---|---|
Table |
Horizontal scroll, sticky header, sticky first column below medium widths. |
Badge |
Status pill. The tone selects an icon automatically, so a badge is never colour alone. |
Tag |
Neutral metadata chip with a smaller radius. Badge says how something is going; Tag says what something is. |
VitalStat |
A single vital or lab readout: value, unit, and an explicitly worded range state. |
Feedback
| Component | Notes |
|---|---|
Toast |
Transient confirmation on espresso. Danger takes role="alert"; everything else takes role="status". |
Tooltip |
Short clarification on hover and focus. Never the only place information lives. |
EmptyState |
State the fact, then offer the next action. title is required. |
Navigation
| Component | Notes |
|---|---|
SideNav |
The product shell rail. Persistent from large widths, off-canvas drawer with focus trap below. |
NavBar |
Marketing and docs top bar, collapsing to a menu button below medium widths. |
Footer |
The closing espresso band, with columns and notes. |
Brand
| Component | Notes |
|---|---|
Icon |
Any interface icon, resolved from the icon set at the brand stroke width of 1.75. An unknown slug degrades to an empty box rather than crashing. |
Logo |
The shipped lockup files in three variants and three themes. Never redrawn in code. |
Glyph |
The brand mark alone. Its animate prop draws six strokes 60 ms apart as a loading affordance. |
Also exported: cx for class composition, resolveLucideIcon and ICON_STROKE_WIDTH, and useFieldId for label and description wiring.
src/types.ts defines the union types every component draws from, which is what keeps the API consistent:
Size = 'sm' | 'md' | 'lg' // 32 / 40 / 48px control heights
ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'inverse' | 'danger'
IconButtonVariant = 'primary' | 'secondary' | 'ghost'
SurfaceTone = 'cream' | 'bone' | 'white' | 'inverse'
StatusTone = 'success' | 'neutral' | 'danger'
BadgeTone = StatusTone | 'accent' | 'ink'
ToastTone = 'info' | 'success' | 'danger'
BandTone = 'bone' | 'espresso'
Align = 'left' | 'right' | 'center'
Side = 'top' | 'bottom' | 'left' | 'right'Every props interface extends the matching HTML element attributes, so anything the platform supports still passes through.
Components read CSS custom properties and never literal values. There is no JavaScript tokens module, no theme object, and no styling library. Components emit block-element-modifier classes such as or-btn, or-btn--primary, or-btn--md, and the stylesheets read var(--token).
Token sources are eight files under src/styles/tokens/, imported by src/styles/index.css in a fixed order:
fonts -> colors -> typography -> spacing -> radius -> elevation -> motion -> base
-> components/_index.css
Component rules come last so they win ties against the element selectors in base.css.
Three custom properties are set inline at runtime rather than in a stylesheet, because they are asset paths a stylesheet cannot know: the glyph source, the logo sources, and the per-navigation logo sources. Everything else is static.
The exact token names and values are on Design system.
Accessibility here is enforced by tests, the Storybook addon, and review, rather than by a lint rule. The ESLint config carries no accessibility plugin, which is worth knowing so you do not assume a rule will catch a mistake.
The patterns the library holds to:
-
A global focus ring.
:focus-visiblegives a 2 px terracotta outline at 2 px offset, defined once inbase.cssand reinforced per component where the default would be clipped. - Real controls stay in the accessibility tree. Checkbox and Radio visually hide the native input and mark the painted box as decoration, rather than rebuilding the control from divs.
-
Switch is a button with
role="switch", named througharia-labelledby, because an HTML label cannot name a button. -
Hints and errors are descriptions, not names. Input wires them through
aria-describedbyso they do not pollute the accessible name. - Focus is trapped and restored in Modal and in the SideNav drawer, with Escape closing both and focus returning to the opener.
-
Never colour alone, enforced structurally rather than by convention.
Badgeattaches a per-tone icon automatically.VitalStatdrops the entire state row when no state label is given, rather than leaving a bare colour.SideNavmarks the active row witharia-currentand a heavier weight, not only a fill. - Touch targets of at least 44 px below medium widths, across every interactive component. Switch grows a transparent pseudo-element because its visible track is under the floor.
-
Hover styles are wrapped in
@media (hover: hover), so a tap never leaves a control stuck in hover skin. -
Reduced motion is honoured globally in
tokens/motion.css, with local handling in Glyph and Modal.
Several stories exist purely to document doctrine, including a badge story showing what colour-alone would look like and an icon story showing the unknown-slug degradation.
Storybook 9 on the Vite builder, with the accessibility and docs addons. Prop tables are generated from the real exported interfaces.
pnpm --filter @openrunic/ui storybook # port 6007
pnpm --filter @openrunic/ui build-storybook # storybook-static/143 stories across 22 files, grouped as Actions, Brand, Data, Feedback, Forms, Navigation, and Surfaces. Every component has a Default and a Responsive story. Backgrounds offer bone, cream, and espresso; viewports offer mobile, tablet, and desktop.
There is no generator script. The conventions are written as comments at the two registry files, and following them is what keeps parallel work from colliding.
- Create
src/components/<Name>/with five files: the component, its CSS, its stories, its tests, and a folder barrel. - Put every visual rule in
<Name>.css. Never import CSS from a.tsxfile; onlysrc/index.tsdoes a side-effect CSS import. - Draw props from the shared vocabulary in
types.tsrather than inventing a parallel size or tone union. - Read tokens. No hex values, no off-scale spacing, no invented shadows.
- Add one line to
src/index.ts, alphabetical by component name, value export then type export. The file is organised one export per line specifically so parallel agents never touch the same line. - Add one
@importtosrc/components/_index.css, alphabetical, one line. Same reason. - Write stories including a
Defaultand aResponsive, and a doctrine story if the component has a way to misuse it. - Write tests. The suite has 242 cases across 25 files and the floors are strict, so a thin test will not pass.
pnpm --filter @openrunic/ui testjsdom environment, Testing Library, and the v8 coverage provider, with thresholds of 95 on all four of statements, branches, functions, and lines. Barrels, stories, tests, and types.ts are excluded from coverage.
Like the web app, the vitest config deliberately omits the React plugin. Its second transform pass double-instruments files under coverage and roughly halves reported numbers. The plugin is used only for the Vite build and for Storybook.
One CI nuance worth knowing: the merged coverage gate in CI covers web and api only. packages/ui runs its suite in CI but does not report into that gate, so its 95 percent floors are enforced by its own vitest config on every local and CI run of the package suite.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.