Architecture
The core rule
Ports (interfaces) → Core logic ← Adapters (implementations)
Core logic depends only on TypeScript interfaces (ports). Adapters implement those interfaces. Core never imports adapters. Adapters never import each other.
The deletion test
Remove any directory under src/adapters/. The core still compiles. No adapter is on the import path of any component, hook, store, or form.
This is the single mechanical invariant the architecture enforces. If you add code that makes the deletion test fail, the dependency boundary has been violated.
What is ported vs built-in
Ported — 16 native features
These are defined as TypeScript interfaces in src/ports/ and re-exported from the main barrel as export type. They have no implementation in core.
| Port | Capability |
|---|---|
BiometricProvider | Fingerprint / Face ID authentication |
CameraProvider | Take pictures via device camera |
SecureStorageProvider | Encrypted key-value storage |
HapticsProvider | Tactile feedback (impact, notification, selection) |
NotificationProvider | Local + push notifications, badge count |
PurchaseProvider | In-app purchases and subscriptions |
PermissionsProvider | Check and request OS permissions |
ShareProvider | Native share sheet |
ClipboardProvider | Read/write system clipboard |
KeychainProvider | Secure credential storage |
LocationProvider | GPS position, watch updates, foreground/background |
MediaLibraryProvider | Pick images/videos, save to library, list albums |
MapProvider | Render a native map with markers |
WebBrowserProvider | In-app browser (SFSafariViewController / Chrome Custom Tabs) |
ContactsProvider | Read device contacts, search, request permission |
ReviewProvider | Trigger native app-review prompt |
Built-in — no porting required
These ship with the SDK and have zero native dependencies:
| System | What it provides |
|---|---|
| Observable store | createStore — reactive key-value state, no external dependency |
| API client | createApiClient — Bearer auth, refresh, retry, offline queue, GET dedup |
| Theme engine | defineTheme, resolveBoxStyle, resolveTextStyle — token-based styles |
| Form engine | useForm — Zod-backed validation, field-level errors, submit lifecycle |
| Navigation helpers | BottomSheet with snap points and keyboard awareness |
| Animation primitives | FadeIn, SlideUp, ScalePress, SpringBounce, Stagger (via ./animations) |
| All UI components | 15 components: primitives, layout, inputs, overlays, feedback |
| All hooks | 17 hooks: auth, network, biometrics, keyboard, orientation, deep links, and more |
Hard dependencies
The only mandatory dependency is zod. It validates theme tokens, config shapes, and form schemas at runtime.
Everything else is an optional peer dependency. Install only what your project uses.
Headless hook pattern
Every component follows the same four-file structure:
src/components/<category>/<name>/
types.ts — Props interface + any component-specific types
use-<name>.ts — Pure function: accepts ThemeTokens + props, returns styles + state
<name>.tsx — React shell: calls useTheme(), passes tokens to hook, renders native primitive
index.ts — Re-exports hook and component
The headless hook is the primary API surface. It has no React import. You can call it outside a component tree — useful in tests, storybooks, or server-side style generation.
import { useBox } from '@objectifthunes/limestone-sdk';
import type { ThemeTokens, BoxStyleProps } from '@objectifthunes/limestone-sdk';
// Compute styles without mounting anything
const style = useBox(tokens, { p: 'lg', bg: 'primary', rounded: 'md' });
The .tsx component is a thin shell:
// Simplified illustration of the pattern
function Box({ children, ...props }: BoxProps) {
const { tokens } = useTheme();
const style = useBox(tokens, props);
return <View style={style}>{children}</View>;
}
Token-based theming
Themes flow through the component tree in one direction:
ThemeAdapter (defineTheme result)
→ ThemeProvider (via LimestoneProvider)
→ useTheme() in every component
→ resolveBoxStyle / resolveTextStyle
→ StyleSheet.create (React Native)
resolveBoxStyle maps shorthand props to React Native style fields using the token set. p="lg" becomes paddingHorizontal: tokens.spacing.lg (24 by default). bg="primary" becomes backgroundColor: tokens.colors.primary.
No CSS. No class names. No runtime string interpolation. Every style is a resolved number or string derived from the token set.
The store
createStore is a minimal observable without any external state library:
import { createStore } from '@objectifthunes/limestone-sdk';
const store = createStore({ count: 0, user: null });
// Set by value
store.set({ count: 1 });
// Set by updater
store.set((prev) => ({ ...prev, count: prev.count + 1 }));
// Subscribe
const unsub = store.subscribe(() => console.log(store.get()));
// Read
const state = store.get();
LimestoneProvider creates a single Store<AppState> and makes it available via useLimestone().store and useStore(). Auth state, network state, and queued mutations all live in this store.
Error hierarchy
All SDK errors extend AppError. Use isAppError(e) to narrow in catch blocks.
| Class | Code | HTTP status |
|---|---|---|
NotFoundError | NOT_FOUND | 404 |
UnauthorizedError | UNAUTHORIZED | 401 |
ForbiddenError | FORBIDDEN | 403 |
ValidationError | VALIDATION_ERROR | 400 |
ConflictError | CONFLICT | 409 |
RateLimitError | RATE_LIMITED | 429 |
InternalError | INTERNAL_ERROR | 500 |
import { isAppError, NotFoundError } from '@objectifthunes/limestone-sdk';
try {
const user = await apiClient.get('/users/me');
} catch (e) {
if (isAppError(e) && e instanceof NotFoundError) {
// handle 404
}
}