UI architecture
Three layers. The reasoning is in ADR-0008; this page is how to work in it.
| Layer | Where | May do | Must not |
|---|---|---|---|
| Container | src/app/** | Call a use-* hook, wire props, navigate | Style anything |
| Design component | src/features/<area>/<name>-screen.tsx | Lay out a whole screen | Fetch, mutate, read the clock |
| Composed component | src/features/<area>/*.tsx | Know about crags and routes | Fetch, mutate |
| Primitive | src/components/** | Be reused anywhere | Know any domain |
| Data | src/features/<area>/use-*.ts | SQLite, sync, react-query | Render |
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.
| Layout | VStack, HStack, Spacer, Screen, Divider |
| Content | Text, Surface, Glass, Tag, Field, EmptyState |
| Controls | Button, TextField, SegmentedControl, SwitchRow, ListRow |
| Feedback | Spinner, Skeleton |
| Icons | Icon + 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/**orcomponents/**imports@/lib/db,@/lib/api,@/lib/sync,@/lib/identityor@tanstack/react-query - Anything in
src/app/**importsStyleSheet - Anything outside the kit imports
Textfromreact-nativeinstead 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
- Kit first. Check
src/components/uibefore writing anything. If you are about to write the third variation of something, it belongs in the kit. - 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. - 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. - The hook.
use-<thing>.ts. Everything that touches SQLite or sync. - 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, andE2 5cis a wider badge thanV4. - Cover the unhappy states. They are the reason this structure exists. A screen with only a
Populatedstory has not been reviewed. fn()fromstorybook/testfor 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.