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.
| Consumer | Package |
|---|---|
| Expo app | @statshub/ui-native |
web, admin | @statshub/ui-web |
| One app only | Keep 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.
UIProvidercomposes the providers; the app root wraps it inRootErrorBoundaryand passesAppErrorBoundaryas 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
RoutePresentationvocabulary 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 popoverRead 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 notDo not add render tests, snapshots or layout assertions — see the testing policy
in the Expo app's AGENTS.md.