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.

PortCapability
BiometricProviderFingerprint / Face ID authentication
CameraProviderTake pictures via device camera
SecureStorageProviderEncrypted key-value storage
HapticsProviderTactile feedback (impact, notification, selection)
NotificationProviderLocal + push notifications, badge count
PurchaseProviderIn-app purchases and subscriptions
PermissionsProviderCheck and request OS permissions
ShareProviderNative share sheet
ClipboardProviderRead/write system clipboard
KeychainProviderSecure credential storage
LocationProviderGPS position, watch updates, foreground/background
MediaLibraryProviderPick images/videos, save to library, list albums
MapProviderRender a native map with markers
WebBrowserProviderIn-app browser (SFSafariViewController / Chrome Custom Tabs)
ContactsProviderRead device contacts, search, request permission
ReviewProviderTrigger native app-review prompt

Built-in — no porting required

These ship with the SDK and have zero native dependencies:

SystemWhat it provides
Observable storecreateStore — reactive key-value state, no external dependency
API clientcreateApiClient — Bearer auth, refresh, retry, offline queue, GET dedup
Theme enginedefineTheme, resolveBoxStyle, resolveTextStyle — token-based styles
Form engineuseForm — Zod-backed validation, field-level errors, submit lifecycle
Navigation helpersBottomSheet with snap points and keyboard awareness
Animation primitivesFadeIn, SlideUp, ScalePress, SpringBounce, Stagger (via ./animations)
All UI components15 components: primitives, layout, inputs, overlays, feedback
All hooks17 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.

ClassCodeHTTP status
NotFoundErrorNOT_FOUND404
UnauthorizedErrorUNAUTHORIZED401
ForbiddenErrorFORBIDDEN403
ValidationErrorVALIDATION_ERROR400
ConflictErrorCONFLICT409
RateLimitErrorRATE_LIMITED429
InternalErrorINTERNAL_ERROR500
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
  }
}