StatsHub Docs

Add a UI component

Deciding between ui-native, ui-web and the app itself — and the boundary rules a shared kit has to keep.

Where it goes

There is no shared component layer, and there cannot be one: the Expo app is React Native, the web apps are Base UI and Tailwind against the DOM, and there is no react-native-web in this repository. The two kits are parallel, not shared.

ConsumerPackage
Expo app@statshub/ui-native
web, admin@statshub/ui-web
One app onlyKeep it in that app

That last row matters. A component with one consumer in a shared package is indirection with no sharing — put it in the app and move it when a second consumer appears.

Importing from ui-native

Deep paths, always. There is no barrel and no main.

import { Button } from "@statshub/ui-native/button/button";
import { useSurfaceStyle } from "@statshub/ui-native/sheet/surface-style";

What a shared kit must not know

@statshub/ui-native renders. It does not decide where a failure is reported or how the app talks to disk. Three seams exist because of that, and each is the app's half:

  • Error boundaries. UIProvider composes the providers; the app root wraps it in RootErrorBoundary and passes AppErrorBoundary as children.
  • Persisted preferences. The kit owns the surface-style store; the app owns reading it at launch and writing each change.
  • Route tables. The kit declares the RoutePresentation vocabulary because sheets and headers read it. Which route gets which presentation stays in the app.

A kit import that reaches back into an app is the smell

If a new component needs @/lib/..., it is not a kit component yet. Either the thing it needs belongs in the kit too, or the component belongs in the app. Both answers are fine; a package importing from an app is not.

Adding a web primitive

apps/statshub-web's components.json is pinned to shadcn's base-vega style and aliases ui to @statshub/ui-web/components, so the generator writes into the package rather than the app:

cd apps/statshub-web && bunx shadcn@latest add popover

Read the Radix-to-Base-UI translation table before you touch the generated wrapper. A wrapper that forwards asChild straight through renders nothing and raises no error.

Finishing it

A component is not done when it renders. It needs the doc comment saying why it is built that way, including the difficult states and their fallbacks — see conventions for the shape, and match the surrounding comment density.

bun run typecheck
bun run lint
bunx expo export --platform ios   # catches what tsc does not

Do not add render tests, snapshots or layout assertions — see the testing policy in the Expo app's AGENTS.md.

On this page