Skip to main content

UI architecture

Three layers. The reasoning is in ADR-0008; this page is how to work in it.

LayerWhereMay doMust not
Containersrc/app/**Call a use-* hook, wire props, navigateStyle anything
Design componentsrc/features/<area>/<name>-screen.tsxLay out a whole screenFetch, mutate, read the clock
Composed componentsrc/features/<area>/*.tsxKnow about crags and routesFetch, mutate
Primitivesrc/components/**Be reused anywhereKnow any domain
Datasrc/features/<area>/use-*.tsSQLite, sync, react-queryRender

The component kit

src/components/ui/ is the house kit — domain-free building blocks in the spirit of an unstyled component library, but styled to our theme. Nothing in it knows what a crag is.

LayoutVStack, HStack, Spacer, Screen, Divider
ContentText, Surface, Glass, Tag, Field, EmptyState
ControlsButton, TextField, SegmentedControl, SwitchRow, ListRow
FeedbackSpinner, Skeleton
IconsIcon + the registry in icons.ts

Import from the barrel so the kit's surface is one obvious list:

import { Button, HStack, Icon, Text } from '@/components/ui';

Anything that knows about climbing — GradeBadge, Stars, TopoOverlay — lives one level up in src/components/, and anything that knows about a particular screen lives in its feature folder.

Icons are named for meaning

icons.ts is a closed registry: back, not chevron.left. Two reasons — an SF Symbol name scattered through fifty files is impossible to change, and a semantic name is the only way to keep the same idea looking the same everywhere.

<Icon name="tick" size={20} color="success" label="Ticked" />

SF Symbols on iOS, because they are weight-matched to the system font and respond to Dynamic Type. Everywhere else, including Storybook, falls back to a plain glyph — deliberately plain, since a half-matched icon set reads worse than an obvious substitute.

Adding one: pick the SF Symbol Apple already uses for that meaning in its own apps. Do not invent a metaphor.

Spacing comes from the scale

VStack gap="three", never gap={16}. The scale is in src/constants/theme.ts, and taking a key rather than a number is what keeps the rhythm consistent across screens nobody wrote together.

Every component has a story, and CI checks

pnpm check:stories fails if any component in src/components/ or src/features/ has no sibling *.stories.tsx. It runs in CI on every PR, forks included.

A component nobody can review without running the app is the situation this structure exists to avoid, so the rule is mechanical rather than a matter of discipline. Hooks, barrels and the icon registry are exempt — they render nothing.

The boundaries are lint rules

Not conventions. apps/mobile/eslint.config.js fails CI if:

  • Anything in features/** or components/** imports @/lib/db, @/lib/api, @/lib/sync, @/lib/identity or @tanstack/react-query
  • Anything in src/app/** imports StyleSheet
  • Anything outside the kit imports Text from react-native instead of @/components/ui — raw text skips the theme and the type scale

Stories are exempt from the first rule — they import fixtures, not data access.

Adding a screen

  1. Kit first. Check src/components/ui before writing anything. If you are about to write the third variation of something, it belongs in the kit.
  2. The design component. src/features/<area>/<name>-screen.tsx. Take everything as props, including callbacks. Handle loading and empty explicitly — on an offline-first app those are normal outcomes, not edge cases.
  3. The stories. <name>-screen.stories.tsx, next to it. One per state you care about. If a state is awkward to express as props, the component is holding something it should not.
  4. The hook. use-<thing>.ts. Everything that touches SQLite or sync.
  5. The container. src/app/.... Hook in, props out, navigation. Should be about 20 lines.

Storybook

pnpm storybook # http://localhost:6006
pnpm storybook:build # what CI deploys

Every PR gets a deployed Storybook, linked from the preview comment — see CI/CD.

:::warning Storybook shows the web fallbacks, not the real app It renders through react-native-web. isLiquidGlassAvailable() is false there, so GlassSurface draws its solid fallback card; SF Symbols fall back to text; native tabs do not exist at all. Those fallbacks are explicitly not the design — see iOS native feel.

Use it for layout, copy, and state coverage. Sign off the native look on a simulator or a device. :::

The toolbar has a Scheme toggle that genuinely re-themes components, not just the frame — ColorSchemeOverride in src/hooks/color-scheme-context.ts is what makes that work, and tests can use the same context.

Writing a story

const meta = {
title: 'Screens/Crag list',
component: CragListScreen,
args: { crags, loading: false, search: '', onSearchChange: fn(), onSelectCrag: fn() },
} satisfies Meta<typeof CragListScreen>;

export const NothingDownloaded: Story = { args: { crags: [] } };
  • Use src/fixtures.ts. Real crag and route names, because "Portland — Cheyne Wear" wraps where "Crag 1" does not, and E2 5c is a wider badge than V4.
  • Cover the unhappy states. They are the reason this structure exists. A screen with only a Populated story has not been reviewed.
  • fn() from storybook/test for callbacks, so actions show in the panel.

Anything time-dependent takes a formatted string

ProfileScreen takes lastSyncedLabel: string, not a timestamp. Reading the clock during render is impure, React's lint rule rejects it, and it makes stories drift to "just now" as they sit open. The hook captures the clock in state and formats; the screen displays.

The same applies to anything else non-deterministic — random ids, the network, the device locale. If a design component needs it, it arrives as a prop.